Compare commits

...

26 Commits

Author SHA1 Message Date
Abhi Kumar
ca45113bdd chore: pr review changes 2026-10-27 15:45:32 +05:30
Abhi Kumar
d567ff1264 style(charts): format scatter chart data with oxfmt 0.73 2026-10-27 15:45:32 +05:30
Abhi Kumar
b8104ba33b refactor(charts): trim scatter chart layer comments and dead code 2026-10-27 15:45:32 +05:30
Abhi Kumar
1e14562391 fix(charts): pick the scatter auto scale by how the points spread
Max/min decades let one outlier decide and never left linear with zeros.
Compare middle-half coverage instead, falling back to symlog.
2026-10-27 15:45:32 +05:30
Abhi Kumar
00e761639a fix(charts): restore the default log axis grid
Assisted-by: Claude Opus 5.5
2026-10-27 15:45:32 +05:30
Abhi Kumar
3da54d31b6 fix(dashboard): honour scatter plot soft limits on every axis scale
Only Auto weighs the soft limits when choosing a scale; an explicit scale keeps its choice.

Assisted-by: Claude Opus 5.5
2026-10-27 15:45:32 +05:30
Abhi Kumar
4d3286bf56 feat(charts): draw axis lines in the grid's style by default
Padding the scatter range moved the plot edges off the outer grid lines, which
had stood in for the axes.

Assisted-by: Claude Opus 5.5
2026-10-27 15:45:32 +05:30
Abhi Kumar
5b56a8284e fix(charts): keep scatter dots whole at the plot edges
uPlot clips series to the plot area, so a dot at an extreme value lost the half
past the edge. Each scatter scale now pads by the largest dot's radius in
pixels. Log axes are no longer snapped to powers of ten, since snapping after
the pad could add a whole empty decade; they place 1/2/5 ticks themselves.

Assisted-by: Claude Opus 5.5
2026-10-27 15:45:32 +05:30
Abhi Kumar
0cbd533e1f fix(charts): draw log axis grid lines at powers of ten only
uPlot splits a log axis at every 1–9 × 10ⁿ, and a line on each buried the
points under a mesh.

Assisted-by: Claude Opus 5.5
2026-10-27 15:45:32 +05:30
Abhi Kumar
43185c25a6 feat(charts): report the scatter point a click lands on
The shared click data finds its series by x position, which a faceted plot has
no axis for; the hit is read off the cursor instead.

Assisted-by: Claude Opus 5.5
2026-10-27 15:45:32 +05:30
Abhi Kumar
63b550c02f fix(charts): forward plotRef and skip x lookups on faceted clicks
ChartWrapper dropped its plotRef prop, and a click on a faceted plot threw in
posToIdx, which reads the null data[0].

Assisted-by: Claude Opus 5.5
2026-10-27 15:45:32 +05:30
Abhi Kumar
dbd328e22d fix(charts): outline the hovered scatter point
Snap the ring to whole pixels so uPlot's ceil on left/top doesn't shift it
off the disc.

Assisted-by: Claude Opus 5.5
2026-10-27 15:45:32 +05:30
Abhi Kumar
bbfdb56504 feat(charts): scatter axis scales and titles
auto picks log only for positive values spanning three decades; log falls
back to symlog when given values at or below zero.

Assisted-by: Claude Opus 5.5
2026-10-27 15:45:32 +05:30
Abhi Kumar
99c9a744f4 chore: scatter plot base work 2026-10-27 15:45:32 +05:30
Abhi Kumar
e013ebfa08 feat(dashboards): add an axis to scatter plot thresholds 2026-10-27 15:45:31 +05:30
Naman Verma
f79d9f5429 chore: add more validations for scatter plot 2026-10-27 15:45:31 +05:30
Abhi Kumar
3b6cf789a8 feat(dashboards): rename scatter plot dimensions to colorBy and sizeBy 2026-10-27 15:45:31 +05:30
Abhi Kumar
35da6e6c90 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-27 15:45:31 +05:30
Abhi Kumar
92312176f6 feat(dashboards): add point size and opacity to scatter plot spec
The fixed size and the min/max range are both stored so binding or unbinding
the size dimension restores the other's last setting.

Assisted-by: Claude Opus 5.5
2026-10-27 15:45:31 +05:30
Abhi Kumar
527e480142 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-27 15:45:31 +05:30
Abhi Kumar
685777c2a1 feat(dashboards): add axis labels and multi-key colour to scatter plot spec
An empty colour list colours by every group-by label.

Assisted-by: Claude Opus 5.5
2026-10-27 15:45:31 +05:30
Abhi Kumar
0578feab29 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-27 15:45:31 +05:30
Abhi Kumar
29c3341ebb feat(dashboards): add plugin schema for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-27 15:45:31 +05:30
Srikanth Chekuri
19af7782d9 chore(openapi): describe the API payload fields and add request examples (#13200)
#### Description

- The OpenAPI spec carried almost no field descriptions: 5 property
descriptions across 671 schemas before this work. Clients that generate
from it, the MCP server's tool schemas among them, got names and types
but no meaning, format, defaults or dependencies.
- Adds `description` struct tags to every JSON-visible field of the
payloads the API exposes for rules, the query builder v5, field lookups,
metric types, notification channels, saved views, dashboards v2 and the
metrics explorer: 781 descriptions in all (ruletypes 94,
querybuildertypesv5 154, telemetrytypes 41, metrictypes 2,
alertmanagertypes 164, savedviewtypes 26, dashboardtypes 234,
metricsexplorertypes 66). Shared schemas such as `Filter` and
`TelemetryFieldKey` stay signal-neutral; the fields that reference them
carry the signal-specific examples, which #13194 made possible.
- Adds request examples to the registrations that had none: notification
channel create (one per kind), update and test (14), saved view create
and update (5), dashboard create, update and patch (5), metrics treemap
(2). The spec advertises these as payloads the API accepts, so the
`*_examples_test.go` tests decode each one through the same
`UnmarshalJSON` and `Validate` path the handlers run.
- Encodes the constraints the validators enforce where the schema can
carry them: `schemaVersion` pattern, `maxItems` on tags, `maxLength` on
display names.
- Descriptions state the contract at the API boundary, not the
implementation: meaning, unit, precision, defaults, dependencies and
what the caller can observe. Behavior that is accepted but not applied
(`limitBy`, formula `having`, `secondaryAggregations`, several dashboard
fields the UI does not read) is described as such rather than promised.
Scan statistics are described as a cost indicator only.
- Regenerates `docs/api/openapi.yml` and the frontend API clients. The
spec changes are descriptions, examples and the three constraints;
discriminators, required lists and types are unchanged, and the
generated TypeScript types are unchanged apart from JSDoc.

This squashes the reviewed per-package PRs #13195, #13196, #13197,
#13198, #13199, #13201 and #13202 into one change; their review threads
hold the per-finding verification.

#### Issues closed by this PR

Part of SigNoz/nerve-pod#426. Builds on #13194.

Follow-ups filed from the review: SigNoz/pulse-pod#604 (bare `A.0`
aggregation references in formulas never worked), SigNoz/nerve-pod#498
(units registry and `GET /api/v1/units`), SigNoz/nerve-pod#499 (listing
the bundled dashboard icons and logos).

#### Additional Information

Verified with `go build ./...`, the type, apiserver and signoz package
tests, `go run cmd/enterprise/*.go generate openapi` and `cd frontend &&
pnpm generate:api` on every commit. Suggest squash on merge.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 08:35:12 +00:00
Gaurav Tewari
119ab97240 feat: llm o11y improvements (#13229)
#### Description

- **Thread "Go back" stays on the trace:** switching tabs now adds a
browser history entry, so going back from Thread returns to Overview
instead of leaving the trace page.
- **Learn more link:** the Thread empty state now points to
`/docs/ai-observability/` instead of `/docs/llm-observability/`.
- **Empty state UI:** the "no AI spans" Thread state is centered, with
title/description typography matching the "Span not found" state. (In
design it is still left, but let's move it to center it's better.)
- **Span not found only on 404:** an empty thread (span exists, no AI
spans) now shows the no-AI-spans state instead. otherwise user have
click back 2 time :( which is not right .
- **Remove Scroll & key update**: So after feedback from dev we found
scroll in accordian does not feel right. we are removing it. also
"input" key we are using to get json view might be empty sometime. so we
are using formatted onces for both input and output now.
- **Service Dot**: added little margin 

#### Issues closed by this PR

https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=279904779&issue=SigNoz%7Cnerve-pod%7C506

#### Screenshots / Screen Recordings

now - 
<img width="1722" height="1108" alt="image"
src="https://github.com/user-attachments/assets/c8fc6319-f2b8-49e9-80c9-5383616ad5a7"
/>
prev - 

<img width="1710" height="1113" alt="image"
src="https://github.com/user-attachments/assets/d1c13e95-c1a1-4fdb-aebd-ebbe69c286ba"
/>


span details - 
prev- 
<img width="912" height="997" alt="image"
src="https://github.com/user-attachments/assets/d94a4a11-5ab3-4998-b5eb-76375f013115"
/>

now - 
<img width="939" height="1013" alt="image"
src="https://github.com/user-attachments/assets/69224b76-c5f5-4937-8e2d-ff7a1fb5ed78"
/>


service dot- 
before- 
<img width="335" height="81" alt="image"
src="https://github.com/user-attachments/assets/21ad13cb-1050-45e4-82f7-bcdef002d31e"
/>

now - 
<img width="371" height="91" alt="image"
src="https://github.com/user-attachments/assets/18143a71-267b-4923-82ab-eaef1aa94bfa"
/>

#### Additional Information

- Browser back and the header back arrow now also step through tab
switches (Overview → Thread → back lands on Overview).
- The empty-state title is reworded to "This trace has no spans from
AI-related actions".

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-27 08:12:17 +00:00
Aditya Singh
e163ec3b1b feat(saved-views): analytics for saved views and the bottom strip (#13164)
#### Description
- Saved views actions now send analytics events: View created, View
updated, View details edited, View deleted, Link copied, View cleared
and Changes discarded.
- Every event carries `source` (logs, traces, metrics or meter), and
`viewId` when there is a view.
- View created also carries `from`: `header`, `save_as_new` or
`empty_state`.
  - An event fires only after the action succeeds.
- The events follow the trace details pattern: an `events.ts` file and a
`useSavedViewLogEvent` hook that adds `source`.
- Ask Noz in the bottom strip now logs `AI Assistant: Opened` with
`source: bottom_strip`. Strip opens now show in the same funnel as the
header and shortcut opens.
- Support in the bottom strip logs `Bottom Strip: Support clicked`, with
`type` set to `pylon` or `add_card`.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/58

#### Additional Information
- The saved views events use new names. The old per-explorer events,
such as `Logs Explorer: Save view clicked`, stop when #13125 turns on
the flag.
- Opening a view, opening the views panel and searching are not tracked.
- The strip has no conversation history, so #58 item 1 covers Support
only.
2026-10-27 05:45:51 +00:00
142 changed files with 11454 additions and 1638 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,7 @@ const mockPaths = {
const mockTzDate = jest.fn(
(date: Date, _timezone: string) => new Date(date.getTime()),
);
const mockOrient = jest.fn();
// Mock uPlot constructor - this needs to be a proper constructor function
function MockUPlot(
@@ -70,6 +71,7 @@ function MockUPlot(
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
MockUPlot.orient = mockOrient;
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
MockUPlot.pxRatio = 1;

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,7 @@ export const AIAssistantOpenSource = {
Shortcut: 'shortcut',
Cmdk: 'cmdk',
TraceDetails: 'trace_details',
BottomStrip: 'bottom_strip',
} as const;
export type AIAssistantOpenSource =
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];

View File

@@ -1,8 +1,15 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
import {
AIAssistantEvents,
AIAssistantOpenSource,
} from 'container/AIAssistant/events';
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
import {
openAIAssistant,
@@ -37,6 +44,14 @@ function AskNoz(): JSX.Element | null {
// The drawer does not render on the Noz full page, so the button would be inert.
const isAIAssistantPage = pathname.startsWith(ROUTES.AI_ASSISTANT_BASE);
const handleOpen = useCallback((): void => {
void logEvent(AIAssistantEvents.Opened, {
source: AIAssistantOpenSource.BottomStrip,
currentPage: normalizePage(pathname),
});
openAIAssistant();
}, [pathname]);
if (!isAIAssistantEnabled || isDrawerOpen || isAIAssistantPage) {
return null;
}
@@ -57,7 +72,7 @@ function AskNoz(): JSX.Element | null {
color="secondary"
size="sm"
prefix={<Noz size={16} />}
onClick={(): void => openAIAssistant()}
onClick={handleOpen}
aria-label={
showPendingBadge
? `Ask Noz, ${pendingUserInputCount} ${

View File

@@ -1,4 +1,9 @@
import { fireEvent } from '@testing-library/react';
import logEvent from 'api/common/logEvent';
import {
AIAssistantEvents,
AIAssistantOpenSource,
} from 'container/AIAssistant/events';
import { openAIAssistant } from 'container/AIAssistant/store/useAIAssistantStore';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { render } from 'tests/test-utils';
@@ -6,6 +11,10 @@ import { render } from 'tests/test-utils';
import AskNoz from '../AskNoz';
jest.mock('hooks/useIsAIAssistantEnabled');
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('container/AIAssistant/store/useAIAssistantStore', () => ({
openAIAssistant: jest.fn(),
useAIAssistantStore: jest.fn(),
@@ -91,6 +100,17 @@ describe('AskNoz', () => {
expect(mockOpen).toHaveBeenCalledTimes(1);
});
it('reports the open as coming from the strip', () => {
const { getByRole } = render(<AskNoz />);
fireEvent.click(getByRole('button'));
expect(logEvent).toHaveBeenCalledWith(
AIAssistantEvents.Opened,
expect.objectContaining({ source: AIAssistantOpenSource.BottomStrip }),
);
});
});
describe('pending badge', () => {

View File

@@ -1,9 +1,16 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import logEvent from 'api/common/logEvent';
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { MessageSquareText } from '@signozhq/icons';
import {
BottomStripEventKeys,
BottomStripEvents,
BottomStripSupportType,
} from '../events';
function SupportButton(): JSX.Element | null {
const chatSupport = useChatSupport();
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
@@ -14,7 +21,13 @@ function SupportButton(): JSX.Element | null {
}
const handleClick = (): void => {
if (chatSupport === ChatSupportState.NeedsCard) {
const needsCard = chatSupport === ChatSupportState.NeedsCard;
void logEvent(BottomStripEvents.SupportClicked, {
[BottomStripEventKeys.Type]: needsCard
? BottomStripSupportType.AddCard
: BottomStripSupportType.Pylon,
});
if (needsCard) {
setIsAddCreditCardModalOpen(true);
return;
}

View File

@@ -1,9 +1,16 @@
import { fireEvent } from '@testing-library/react';
import logEvent from 'api/common/logEvent';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { render } from 'tests/test-utils';
import { BottomStripEvents, BottomStripSupportType } from '../../events';
import SupportButton from '../SupportButton';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('hooks/useChatSupport', () => ({
...jest.requireActual('hooks/useChatSupport'),
useChatSupport: jest.fn(),
@@ -37,6 +44,9 @@ describe('SupportButton', () => {
fireEvent.click(getByTestId(BUTTON));
expect(window.Pylon).toHaveBeenCalledWith('show');
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
type: BottomStripSupportType.Pylon,
});
});
it('does not open the credit card modal', () => {
@@ -64,6 +74,9 @@ describe('SupportButton', () => {
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
expect(window.Pylon).not.toHaveBeenCalled();
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
type: BottomStripSupportType.AddCard,
});
});
});

View File

@@ -0,0 +1,12 @@
export enum BottomStripEvents {
SupportClicked = 'Bottom Strip: Support clicked',
}
export enum BottomStripEventKeys {
Type = 'type',
}
export enum BottomStripSupportType {
Pylon = 'pylon',
AddCard = 'add_card',
}

View File

@@ -19,6 +19,7 @@ import {
SAVED_VIEW_LOAD_FAILED_NAME,
SAVED_VIEW_TOAST_POSITION,
} from './constants';
import { SavedViewCreatedFrom } from './events';
import { useActiveSavedView } from './hooks/useActiveSavedView';
import { useSaveNewView } from './hooks/useSaveNewView';
import { useSavedViewActions } from './hooks/useSavedViewActions';
@@ -54,7 +55,17 @@ function SavedViewsHeader({
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
const handleSave = useSaveNewView(createView);
const saveNewView = useSaveNewView(createView);
const handleSave = useCallback(
(displayName: string): Promise<boolean> =>
saveNewView(
displayName,
modalMode === 'saveAsNew'
? SavedViewCreatedFrom.SaveAsNew
: SavedViewCreatedFrom.Header,
),
[saveNewView, modalMode],
);
const handleUpdate = useCallback(async (): Promise<void> => {
if (view && (await updateView(view))) {

View File

@@ -10,6 +10,7 @@ import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { SAVED_VIEWS_LIST_LOADING_ROWS } from './constants';
import DeleteSavedViewDialog from './DeleteSavedViewDialog';
import { SavedViewCreatedFrom } from './events';
import { useSavedViewActions } from './hooks/useSavedViewActions';
import { useSavedViewHoverCard } from './hooks/useSavedViewHoverCard';
import { useSavedViewRowActions } from './hooks/useSavedViewRowActions';
@@ -35,7 +36,7 @@ function SavedViewsList({
search: string;
}): JSX.Element {
const { viewKey } = useGetSavedViewParams();
const { selectView, clearView, createView, isSaving } =
const { selectView, clearView, resetView, createView, isSaving } =
useSavedViewActions(source);
const saveNewView = useSaveNewView(createView);
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
@@ -102,10 +103,10 @@ function SavedViewsList({
return;
}
if (pendingAction.view.id === viewKey) {
clearView();
resetView();
}
setPendingAction(null);
}, [pendingAction, deleteView, viewKey, clearView]);
}, [pendingAction, deleteView, viewKey, resetView]);
if (isLoading) {
return (
@@ -141,7 +142,9 @@ function SavedViewsList({
<SavedViewsEmptyState
isSearching={search.trim().length > 0}
isSaving={isSaving}
onSave={saveNewView}
onSave={(displayName): Promise<boolean> =>
saveNewView(displayName, SavedViewCreatedFrom.EmptyState)
}
/>
);
}

View File

@@ -0,0 +1,322 @@
import {
act,
fireEvent,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewCreatedFrom, SavedViewEvents } from '../events';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
expectRowUsable,
expectUsable,
explorerUrl,
makeView,
MENU_CHECK_TIMEOUT,
queryWith,
renderWithExplorerProviders,
viewUrl,
} from './savedViewsTestUtils';
const logEventMock = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): void => logEventMock(...args),
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
// copy-to-clipboard falls back to window.prompt in jsdom.
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): [unknown, jest.Mock] => [null, jest.fn()],
}));
const PATH = ROUTES.TRACES_EXPLORER;
const source = SavedviewtypesSourceDTO.traces;
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
function renderAll(
url = explorerUrl(PATH, {}),
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<>
<SavedViewsHeader source={source} onOpenViews={jest.fn()} />
<SavedViewsPanel source={source} onClose={jest.fn()} />
</>,
url,
);
}
const row = (name: string): HTMLElement =>
screen
.getAllByTestId('saved-views-row')
.find((element) => element.textContent?.includes(name)) as HTMLElement;
// Buttons and menu rows ignore clicks until their permission check settles.
async function clickWhenUsable(testId: string): Promise<void> {
await waitFor(() => expectUsable(screen.getByTestId(testId)));
await userEvent.click(screen.getByTestId(testId));
}
async function chooseMenuItem(testId: string): Promise<void> {
const item = await screen.findByTestId(testId);
await waitFor(() => expectRowUsable(item), MENU_CHECK_TIMEOUT);
fireEvent.click(item);
}
async function chooseFromRowMenu(viewName: string, key: string): Promise<void> {
await screen.findByTestId('saved-views-list');
fireEvent.click(within(row(viewName)).getByTestId('saved-views-row-menu'));
await chooseMenuItem(`saved-views-row-menu-item-${key}`);
}
async function chooseFromSaveMenu(key: string): Promise<void> {
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
await chooseMenuItem(`saved-views-save-changes-item-${key}`);
}
async function submitName(name: string): Promise<void> {
await userEvent.type(screen.getByTestId('save-view-name'), name);
await userEvent.click(screen.getByTestId('save-view-submit'));
}
async function waitForView(name: string): Promise<void> {
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(name),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
);
}
const callsOf = (event: SavedViewEvents): unknown[][] =>
logEventMock.mock.calls.filter(([name]) => name === event);
describe('SavedViews analytics', () => {
beforeEach(() => {
localStorage.clear();
logEventMock.mockClear();
jest.mocked(toast.error).mockClear();
});
it('logs a view created from the header', async () => {
mockSavedViewsApi([errors], { createdId: 'view-new' });
renderAll();
await clickWhenUsable('saved-views-create');
await submitName('Slow checkout');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
source,
viewId: 'view-new',
from: SavedViewCreatedFrom.Header,
}),
);
});
it('logs a view created from the empty state', async () => {
mockSavedViewsApi([], { createdId: 'view-new' });
renderAll();
await screen.findByTestId('saved-views-list-create');
await clickWhenUsable('saved-views-list-create');
await submitName('Slow checkout');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
source,
viewId: 'view-new',
from: SavedViewCreatedFrom.EmptyState,
}),
);
});
it('does not log a create that fails', async () => {
mockSavedViewsApi([errors], { createStatus: 500 });
renderAll();
await clickWhenUsable('saved-views-create');
await submitName('Slow checkout');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(callsOf(SavedViewEvents.ViewCreated)).toHaveLength(0);
});
describe('on a changed view', () => {
async function openDirtyView(): Promise<void> {
const { history } = renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
act(() => {
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
);
});
await screen.findByTestId('saved-views-discard');
}
it('does not log an update that fails', async () => {
mockSavedViewsApi([errors], { updateStatus: 500 });
await openDirtyView();
await chooseFromSaveMenu('update');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(callsOf(SavedViewEvents.ViewUpdated)).toHaveLength(0);
});
it('logs an update', async () => {
mockSavedViewsApi([errors]);
await openDirtyView();
await chooseFromSaveMenu('update');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewUpdated, {
source,
viewId: errors.id,
}),
);
});
it('logs a view created with save as new', async () => {
mockSavedViewsApi([errors], { createdId: 'view-copy' });
await openDirtyView();
await chooseFromSaveMenu('save-as-new');
await submitName('Errors copy');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
source,
viewId: 'view-copy',
from: SavedViewCreatedFrom.SaveAsNew,
}),
);
});
it('logs discarded changes', async () => {
mockSavedViewsApi([errors]);
await openDirtyView();
logEventMock.mockClear();
await userEvent.click(screen.getByTestId('saved-views-discard'));
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ChangesDiscarded, {
source,
viewId: errors.id,
});
});
});
it('logs a cleared view', async () => {
mockSavedViewsApi([errors]);
renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
await userEvent.click(screen.getByTestId('saved-views-clear'));
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCleared, {
source,
viewId: errors.id,
});
});
it('logs a view cleared from its row', async () => {
mockSavedViewsApi([errors]);
renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
await userEvent.click(await screen.findByTestId('saved-views-row-clear'));
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCleared, {
source,
viewId: errors.id,
});
});
it('logs only the delete when the open view is deleted', async () => {
mockSavedViewsApi([errors]);
renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
await chooseFromRowMenu('Errors', 'delete');
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewDeleted, {
source,
viewId: errors.id,
}),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('My view'),
);
expect(callsOf(SavedViewEvents.ViewCleared)).toHaveLength(0);
});
it('logs a copied link', async () => {
mockSavedViewsApi([errors]);
renderAll();
await chooseFromRowMenu('Errors', 'copyLink');
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.LinkCopied, {
source,
viewId: errors.id,
});
});
it('logs edited view details', async () => {
mockSavedViewsApi([errors]);
renderAll();
await chooseFromRowMenu('Errors', 'editDetails');
await submitName(' renamed');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(
SavedViewEvents.ViewDetailsEdited,
{ source, viewId: errors.id },
),
);
});
it('does not log edited view details that fail to save', async () => {
mockSavedViewsApi([errors], { updateStatus: 500 });
renderAll();
await chooseFromRowMenu('Errors', 'editDetails');
await submitName(' renamed');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(callsOf(SavedViewEvents.ViewDetailsEdited)).toHaveLength(0);
});
it('does not log a delete that fails', async () => {
mockSavedViewsApi([errors], { deleteStatus: 500 });
renderAll();
await chooseFromRowMenu('Errors', 'delete');
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(callsOf(SavedViewEvents.ViewDeleted)).toHaveLength(0);
});
});

View File

@@ -19,11 +19,15 @@ export function mockSavedViewsApi(
views: SavedviewtypesSavedViewDTO[],
{
createStatus = 201,
updateStatus = 204,
deleteStatus = 204,
createdId = 'view-new',
getDelay = 0,
listFailures = 0,
}: {
createStatus?: number;
updateStatus?: number;
deleteStatus?: number;
createdId?: string;
getDelay?: number;
listFailures?: number;
@@ -85,20 +89,38 @@ export function mockSavedViewsApi(
const body = await req.json();
const id = String(req.params.id);
requests.updated.push({ id, body });
if (updateStatus >= 400) {
return res(
ctx.status(updateStatus),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'boom' },
}),
);
}
const index = views.findIndex((v) => v.id === id);
if (index >= 0) {
views[index] = { ...views[index], spec: body.spec };
}
return res(ctx.status(204));
return res(ctx.status(updateStatus));
}),
rest.delete(`${API}/:id`, (req, res, ctx) => {
const id = String(req.params.id);
requests.deleted.push(id);
if (deleteStatus >= 400) {
return res(
ctx.status(deleteStatus),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'boom' },
}),
);
}
const index = views.findIndex((v) => v.id === id);
if (index >= 0) {
views.splice(index, 1);
}
return res(ctx.status(204));
return res(ctx.status(deleteStatus));
}),
);
return requests;

View File

@@ -0,0 +1,23 @@
export enum SavedViewEvents {
ViewCreated = 'Saved Views: View created',
ViewUpdated = 'Saved Views: View updated',
ViewDetailsEdited = 'Saved Views: View details edited',
ViewDeleted = 'Saved Views: View deleted',
LinkCopied = 'Saved Views: Link copied',
ViewCleared = 'Saved Views: View cleared',
ChangesDiscarded = 'Saved Views: Changes discarded',
}
export enum SavedViewEventKeys {
// Injected on every event by useSavedViewLogEvent
Source = 'source',
ViewId = 'viewId',
From = 'from',
}
export enum SavedViewCreatedFrom {
Header = 'header',
SaveAsNew = 'save_as_new',
EmptyState = 'empty_state',
}

View File

@@ -2,14 +2,15 @@ import { useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { SAVED_VIEW_TOAST_POSITION } from '../constants';
import { SavedViewCreatedFrom } from '../events';
import { UseSavedViewActionsResult } from '../types';
export function useSaveNewView(
createView: UseSavedViewActionsResult['createView'],
): (displayName: string) => Promise<boolean> {
): (displayName: string, from: SavedViewCreatedFrom) => Promise<boolean> {
return useCallback(
async (displayName: string): Promise<boolean> => {
if (!(await createView(displayName))) {
async (displayName: string, from: SavedViewCreatedFrom): Promise<boolean> => {
if (!(await createView(displayName, from))) {
return false;
}
toast.success('View created', {

View File

@@ -17,6 +17,7 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
@@ -30,12 +31,18 @@ import {
SAVED_VIEW_TOAST_POSITION,
SAVED_VIEW_URL_PARAMS,
} from '../constants';
import {
SavedViewCreatedFrom,
SavedViewEventKeys,
SavedViewEvents,
} from '../events';
import { UseSavedViewActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { getViewColumnsAndFormatting } from '../utils/getViewColumnsAndFormatting';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
import { useLastUsedView } from './useLastUsedView';
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
// Columns and formatting need no handling here: with a viewKey in the url the
// preference provider reads them from the view, and without one it falls back
@@ -50,6 +57,8 @@ export function useSavedViewActions(
useQueryBuilder();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { setLastUsedView, clearLastUsedView } = useLastUsedView(source);
const { viewKey } = useGetSavedViewParams();
const logSavedViewEvent = useSavedViewLogEvent(source);
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
@@ -107,13 +116,16 @@ export function useSavedViewActions(
}
}
selectView(view);
logSavedViewEvent(SavedViewEvents.ChangesDiscarded, {
[SavedViewEventKeys.ViewId]: view.id,
});
},
[optionsDataSource, preferencesSlice, selectView],
[optionsDataSource, preferencesSlice, selectView, logSavedViewEvent],
);
// The query builder redirects only ever add params, so the url is rebuilt
// here; the time range and everything else a view does not set stays.
const clearView = useCallback((): void => {
const resetView = useCallback((): void => {
clearLastUsedView();
const params = getUnstableCurrentSearchParams();
@@ -121,8 +133,18 @@ export function useSavedViewActions(
safeNavigate(`${pathname}?${params.toString()}`);
}, [clearLastUsedView, pathname, safeNavigate]);
const clearView = useCallback((): void => {
resetView();
logSavedViewEvent(SavedViewEvents.ViewCleared, {
[SavedViewEventKeys.ViewId]: viewKey,
});
}, [resetView, logSavedViewEvent, viewKey]);
const createView = useCallback(
async (displayName: string): Promise<string | undefined> => {
async (
displayName: string,
from: SavedViewCreatedFrom,
): Promise<string | undefined> => {
try {
const { data } = await createSavedView({
data: {
@@ -137,6 +159,10 @@ export function useSavedViewActions(
[QueryParams.viewKey]: data.id,
});
setLastUsedView(data.id, displayName);
logSavedViewEvent(SavedViewEvents.ViewCreated, {
[SavedViewEventKeys.ViewId]: data.id,
[SavedViewEventKeys.From]: from,
});
return data.id;
} catch (error) {
toast.error(
@@ -158,6 +184,7 @@ export function useSavedViewActions(
stagedQuery,
currentQuery,
setLastUsedView,
logSavedViewEvent,
],
);
@@ -173,6 +200,9 @@ export function useSavedViewActions(
},
});
await invalidateViews(view.id);
logSavedViewEvent(SavedViewEvents.ViewUpdated, {
[SavedViewEventKeys.ViewId]: view.id,
});
return true;
} catch (error) {
toast.error(
@@ -185,13 +215,14 @@ export function useSavedViewActions(
return false;
}
},
[updateSavedView, source, toCurrentSpec, invalidateViews],
[updateSavedView, source, toCurrentSpec, invalidateViews, logSavedViewEvent],
);
return {
selectView,
revertView,
clearView,
resetView,
createView,
updateView,
isSaving: isCreating || isUpdating,

View File

@@ -0,0 +1,24 @@
import { useCallback } from 'react';
import logEvent from 'api/common/logEvent';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { SavedViewEventKeys, SavedViewEvents } from '../events';
export type SavedViewLogEvent = (
event: SavedViewEvents,
attributes?: Record<string, unknown>,
) => void;
export function useSavedViewLogEvent(
source: SavedviewtypesSourceDTO,
): SavedViewLogEvent {
return useCallback(
(event: SavedViewEvents, attributes: Record<string, unknown> = {}): void => {
void logEvent(event, {
[SavedViewEventKeys.Source]: source,
...attributes,
});
},
[source],
);
}

View File

@@ -22,9 +22,11 @@ import {
SAVED_VIEW_SCHEMA_VERSION,
SAVED_VIEW_TOAST_POSITION,
} from '../constants';
import { SavedViewEventKeys, SavedViewEvents } from '../events';
import { UseSavedViewRowActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
function showErrorToast(error: unknown, fallback: string): void {
toast.error(
@@ -42,6 +44,7 @@ export function useSavedViewRowActions(
const queryClient = useQueryClient();
const { pathname } = useLocation();
const [, copyToClipboard] = useCopyToClipboard();
const logSavedViewEvent = useSavedViewLogEvent(source);
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
useUpdateSavedView();
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
@@ -55,8 +58,11 @@ export function useSavedViewRowActions(
});
copyToClipboard(getAbsoluteUrl(path));
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.LinkCopied, {
[SavedViewEventKeys.ViewId]: view.id,
});
},
[pathname, copyToClipboard],
[pathname, copyToClipboard, logSavedViewEvent],
);
const copyViewId = useCallback(
@@ -83,13 +89,16 @@ export function useSavedViewRowActions(
});
await invalidateSavedViews(queryClient, source, view.id);
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.ViewDetailsEdited, {
[SavedViewEventKeys.ViewId]: view.id,
});
return true;
} catch (error) {
showErrorToast(error, 'Could not update the view');
return false;
}
},
[updateSavedView, source, queryClient],
[updateSavedView, source, queryClient, logSavedViewEvent],
);
// The deleted view's own query is left alone: refetching it would fail
@@ -100,13 +109,16 @@ export function useSavedViewRowActions(
await deleteSavedView({ pathParams: { id: view.id } });
await invalidateSavedViews(queryClient, source);
toast.success('View deleted', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.ViewDeleted, {
[SavedViewEventKeys.ViewId]: view.id,
});
return true;
} catch (error) {
showErrorToast(error, 'Could not delete the view');
return false;
}
},
[deleteSavedView, source, queryClient],
[deleteSavedView, source, queryClient, logSavedViewEvent],
);
return {

View File

@@ -10,6 +10,8 @@ import { OptionsQuery } from 'container/OptionsMenu/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewCreatedFrom } from './events';
export type SavedViewSourcePage = DataSource | 'meter';
export type SavedViewOptions = Pick<
@@ -51,7 +53,12 @@ export interface UseSavedViewActionsResult {
selectView: (view: SavedviewtypesSavedViewDTO) => void;
revertView: (view: SavedviewtypesSavedViewDTO) => void;
clearView: () => void;
createView: (displayName: string) => Promise<string | undefined>;
/** Clears the open view without logging it, for flows that are not a clear. */
resetView: () => void;
createView: (
displayName: string,
from: SavedViewCreatedFrom,
) => Promise<string | undefined>;
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
isSaving: boolean;
}

View File

@@ -0,0 +1,89 @@
// Sections own the padding so the footer spans edge to edge.
.container {
font-family: 'Inter';
font-size: 12px;
background: var(--l2-background);
-webkit-font-smoothing: antialiased;
color: var(--l2-foreground);
border-radius: 6px;
border: 1px solid var(--l2-border);
display: flex;
flex-direction: column;
min-width: 200px;
&.pinned {
border-color: var(--ring);
}
}
.divider {
display: block;
width: 100%;
height: 1px;
background-color: var(--l2-border);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-3) var(--spacing-4);
}
// Matches the legend row's marker.
.marker {
width: 10px;
height: 10px;
border-radius: var(--radius);
flex-shrink: 0;
}
.title {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--text-vanilla-100);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
padding: var(--spacing-3) var(--spacing-4);
}
.row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--spacing-4);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
color: var(--text-vanilla-100);
}
.rowMuted {
color: var(--text-vanilla-400);
}
.rowLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowValue {
flex: 0 0 auto;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,136 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { Pin } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { ScatterTooltipProps } from '../types';
import { buildChannelRows, resolveHoveredPoint } from './scatterTooltipContent';
import Styles from './ScatterTooltip.module.scss';
/** Own component: the shared Tooltip lists one row per series at a shared x. */
export default function ScatterTooltip({
uPlotInstance,
dataIndexes,
seriesIndex,
channels,
resolvePointGroupValues,
decimalPrecision,
isPinned,
dismiss,
renderTooltipFooter,
}: ScatterTooltipProps): JSX.Element | null {
const hoveredPoint = useMemo(
() => resolveHoveredPoint(uPlotInstance, seriesIndex, dataIndexes),
[uPlotInstance, seriesIndex, dataIndexes],
);
const rows = useMemo(
() =>
hoveredPoint
? buildChannelRows(hoveredPoint, channels, decimalPrecision)
: [],
[hoveredPoint, channels, decimalPrecision],
);
const labels = useMemo(
() =>
hoveredPoint
? (resolvePointGroupValues?.(
hoveredPoint.seriesIndex,
hoveredPoint.dataIndex,
) ?? [])
: [],
[hoveredPoint, resolvePointGroupValues],
);
if (!hoveredPoint) {
return null;
}
return (
<div
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
data-pinned={isPinned}
data-testid="scatter-tooltip"
>
<div className={Styles.header}>
<span
className={Styles.marker}
style={{ backgroundColor: hoveredPoint.color }}
/>
<Typography.Text
as="span"
className={Styles.title}
title={hoveredPoint.label}
testId="scatter-tooltip-title"
>
{hoveredPoint.label}
</Typography.Text>
{isPinned && (
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<Pin size={12} />}
testId="scatter-tooltip-status"
>
Pinned
</Button>
)}
</div>
<span className={Styles.divider} />
<div className={Styles.rows}>
{rows.map((row) => (
<div
key={row.label}
className={Styles.row}
data-testid="scatter-tooltip-row"
>
<Typography.Text as="span" className={Styles.rowLabel}>
{row.label}
</Typography.Text>
<Typography.Text as="span" className={Styles.rowValue}>
{row.value}
</Typography.Text>
</div>
))}
</div>
{labels.length > 0 && (
<>
<span className={Styles.divider} />
<div className={Styles.rows}>
{labels.map((label) => (
<div
key={label.key}
className={cx(Styles.row, Styles.rowMuted)}
data-testid="scatter-tooltip-label"
>
<Typography.Text
as="span"
className={Styles.rowLabel}
title={label.key}
>
{label.key}
</Typography.Text>
<Typography.Text
as="span"
className={Styles.rowValue}
title={label.value}
>
{label.value}
</Typography.Text>
</div>
))}
</div>
</>
)}
{renderTooltipFooter?.({ isPinned, dismiss })}
</div>
);
}

View File

@@ -0,0 +1,94 @@
import type uPlot from 'uplot';
import {
buildChannelRows,
resolveHoveredPoint,
ScatterHoveredPoint,
} from '../scatterTooltipContent';
jest.mock('components/Graph/yAxisConfig', () => ({
getToolTipValue: jest.fn((value: number | string, unit?: string) =>
`${value} ${unit ?? ''}`.trim(),
),
}));
const plot = {
data: [
null,
[
[10, 20],
[100, 200],
[5, null],
],
[[30], [300]],
],
series: [
{},
{ label: 'cart', stroke: '#ff0000' },
{ label: 'checkout', stroke: (): string => '#00ff00' },
],
} as unknown as uPlot;
describe('resolveHoveredPoint', () => {
it('reads the focused series at its own data index', () => {
expect(resolveHoveredPoint(plot, 1, [null, 1, null])).toStrictEqual({
seriesIndex: 1,
dataIndex: 1,
label: 'cart',
color: '#ff0000',
x: 20,
y: 200,
size: null,
});
});
it('carries the size column when present and resolves function strokes', () => {
expect(resolveHoveredPoint(plot, 1, [null, 0, null])?.size).toBe(5);
expect(resolveHoveredPoint(plot, 2, [null, null, 0])).toMatchObject({
label: 'checkout',
color: '#00ff00',
size: null,
});
});
it('is null without a focused series or an index for it', () => {
expect(resolveHoveredPoint(plot, null, [null, 0, null])).toBeNull();
expect(resolveHoveredPoint(plot, 0, [0, 0, null])).toBeNull();
expect(resolveHoveredPoint(plot, 1, [null, null, null])).toBeNull();
});
});
describe('buildChannelRows', () => {
const point: ScatterHoveredPoint = {
seriesIndex: 1,
dataIndex: 0,
label: 'cart',
color: '#f00',
x: 12,
y: 340,
size: 7,
};
it('formats x and y with their own units', () => {
const rows = buildChannelRows(point, {
x: { label: 'Throughput', unit: 'reqps' },
y: { label: 'p99', unit: 'ms' },
});
expect(rows).toStrictEqual([
{ label: 'Throughput', value: '12 reqps' },
{ label: 'p99', value: '340 ms' },
]);
});
it('adds the size row only when the channel is mapped and the point has one', () => {
const channels = {
x: { label: 'x' },
y: { label: 'y' },
size: { label: 'Errors' },
};
expect(buildChannelRows(point, channels)).toHaveLength(3);
expect(buildChannelRows({ ...point, size: null }, channels)).toHaveLength(2);
});
});

View File

@@ -0,0 +1,89 @@
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import type uPlot from 'uplot';
import type {
ScatterChannel,
ScatterChannels,
ScatterSeriesData,
} from '../../plugins/ScatterPlugin/types';
import { resolveSeriesColor } from './utils';
export interface ScatterHoveredPoint {
seriesIndex: number;
dataIndex: number;
label: string;
color: string;
x: number;
y: number;
size: number | null;
}
export interface ScatterTooltipRow {
label: string;
value: string;
}
export function resolveHoveredPoint(
u: uPlot,
seriesIndex: number | null,
dataIndexes: Array<number | null>,
): ScatterHoveredPoint | null {
if (seriesIndex == null || seriesIndex < 1) {
return null;
}
const dataIndex = dataIndexes[seriesIndex];
const series = u.series[seriesIndex];
const columns = u.data[seriesIndex] as unknown as
| ScatterSeriesData
| undefined;
if (dataIndex == null || !series || !columns) {
return null;
}
const x = columns[0][dataIndex];
const y = columns[1][dataIndex];
if (x == null || y == null) {
return null;
}
return {
seriesIndex,
dataIndex,
label: String(series.label ?? ''),
color: resolveSeriesColor(series.stroke, u, seriesIndex),
x,
y,
size: columns[2]?.[dataIndex] ?? null,
};
}
function formatChannel(
value: number,
channel: ScatterChannel,
decimalPrecision?: PrecisionOption,
): string {
return getToolTipValue(value, channel.unit, decimalPrecision);
}
export function buildChannelRows(
point: ScatterHoveredPoint,
channels: ScatterChannels,
decimalPrecision?: PrecisionOption,
): ScatterTooltipRow[] {
const rows: ScatterTooltipRow[] = [
{
label: channels.x.label,
value: formatChannel(point.x, channels.x, decimalPrecision),
},
{
label: channels.y.label,
value: formatChannel(point.y, channels.y, decimalPrecision),
},
];
if (channels.size && point.size != null) {
rows.push({
label: channels.size.label,
value: formatChannel(point.size, channels.size, decimalPrecision),
});
}
return rows;
}

View File

@@ -6,6 +6,10 @@ import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { LegendItem } from '../config/types';
import { HeatmapSeries, HeatmapYAxis } from '../plugins/HeatmapPlugin/types';
import type {
ScatterChannels,
ScatterPointLabel,
} from '../plugins/ScatterPlugin/types';
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
/**
@@ -119,6 +123,16 @@ export interface HeatmapTooltipProps
groupColor: string;
}
export interface ScatterTooltipProps
extends BaseTooltipProps, TooltipRenderArgs {
channels: ScatterChannels;
/** The group values behind a point, e.g. `service.name` → `cart`. */
resolvePointGroupValues?: (
seriesIndex: number,
dataIndex: number,
) => ScatterPointLabel[];
}
export type TooltipProps =
| TimeSeriesTooltipProps
| BarTooltipProps

View File

@@ -39,6 +39,16 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
};
}
/** Styled like the grid. */
private buildBorderConfig(
grid: uPlot.Axis.Grid | undefined,
): uPlot.Axis.Border | undefined {
if (this.props.showBorder === false || !grid || grid.show === false) {
return undefined;
}
return { show: true, stroke: grid.stroke, width: grid.width };
}
/**
* Build ticks configuration
*/
@@ -59,31 +69,47 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
/**
* Build values formatter for X-axis (time)
* A non-time x axis with a unit or precision formats like a value axis.
*/
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
const { isTimeAxis } = this.props;
const { isTimeAxis, yAxisUnit, decimalPrecision } = this.props;
if (isTimeAxis) {
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
}
if (yAxisUnit !== undefined || decimalPrecision !== undefined) {
return this.buildValueAxisFormatter();
}
return undefined;
}
/**
* Build values formatter for Y-axis (values with units)
* Blanks splits outside the scale; uPlot's asinh splits always include ±threshold.
*/
private buildYAxisValuesFormatter(): uPlot.Axis.Values {
const { yAxisUnit, decimalPrecision } = this.props;
private buildValueAxisFormatter(): uPlot.Axis.Values {
const { yAxisUnit, decimalPrecision, scaleKey } = this.props;
return (_, t): string[] =>
t.map((v) => {
if (v === null || v === undefined || Number.isNaN(v)) {
return (u, t): string[] => {
const scale = u?.scales?.[scaleKey];
const min = scale?.min ?? -Infinity;
const max = scale?.max ?? Infinity;
return t.map((v) => {
if (
v === null ||
v === undefined ||
Number.isNaN(v) ||
v < min ||
v > max
) {
return '';
}
const value = getToolTipValue(v.toString(), yAxisUnit, decimalPrecision);
return `${value}`;
});
};
}
/**
@@ -101,7 +127,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
return scaleKey === 'x'
? this.buildXAxisValuesFormatter()
: scaleKey === 'y'
? this.buildYAxisValuesFormatter()
? this.buildValueAxisFormatter()
: undefined;
}
@@ -149,10 +175,12 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
side = 2, // bottom by default
space,
splits,
filter,
gap = 5, // default gap is 5
} = this.props;
const grid = this.buildGridConfig();
const border = this.buildBorderConfig(grid);
const ticks = this.buildTicksConfig();
const values = this.buildValuesFormatter();
const size = this.buildSizeCalculator();
@@ -177,6 +205,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
if (ticks) {
axisConfig.ticks = ticks;
}
if (border) {
axisConfig.border = border;
}
if (values) {
axisConfig.values = values;
}
@@ -189,6 +220,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
if (space !== undefined) {
axisConfig.space = space;
}
if (filter) {
axisConfig.filter = filter;
}
if (size) {
axisConfig.size = size;
}

View File

@@ -20,6 +20,7 @@ import {
ConfigBuilder,
ConfigBuilderProps,
LegendItem,
PlotMode,
SelectionPreferencesSource,
StackMode,
} from './types';
@@ -66,6 +67,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
private stackMode: StackMode = StackMode.None;
private mode: PlotMode = PlotMode.Aligned;
private cursor: Cursor | undefined;
private hooks: Hooks.Arrays = {};
@@ -163,6 +166,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
return this.stackMode;
}
setMode(mode: PlotMode): void {
this.mode = mode;
}
/**
* Add or merge a scale configuration
*/
@@ -519,6 +526,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
{} as Record<string, uPlot.Scale>,
);
if (this.mode === PlotMode.Faceted) {
config.mode = this.mode as number as uPlot.Mode;
}
config.hooks = this.hooks;
config.select = this.select;

View File

@@ -40,6 +40,7 @@ export class UPlotScaleBuilder extends ConfigBuilder<
range,
thresholds,
logBase = 10,
asinhThreshold,
padMinBy = 0,
padMaxBy = 0.05,
} = this.props;
@@ -99,6 +100,7 @@ export class UPlotScaleBuilder extends ConfigBuilder<
time,
distr,
logBase,
asinhThreshold,
});
const { rangeConfig, hardMinOnly, hardMaxOnly, hasFixedRange } =

View File

@@ -88,6 +88,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
lineConfig.fill = finalFillColor;
} else if (this.props.drawStyle === DrawStyle.Histogram) {
lineConfig.fill = `${finalFillColor}40`;
} else if (this.props.drawStyle === DrawStyle.Scatter) {
lineConfig.fill = `${finalFillColor}${toAlphaHex(resolveFillOpacity(fillOpacity))}`;
} else if (fillMode && fillMode !== FillMode.None) {
const resolvedOpacity = resolveFillOpacity(fillOpacity);
if (fillMode === FillMode.Solid) {
@@ -123,7 +125,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
return { paths: pathBuilder };
}
if (drawStyle === DrawStyle.Points) {
if (isMarkerOnlyDrawStyle(drawStyle)) {
return { paths: (): null => null };
}
@@ -195,6 +197,10 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
if (drawStyle === DrawStyle.Points) {
return true;
}
// The discs are the series path; uPlot's own points would double-draw them.
if (drawStyle === DrawStyle.Scatter) {
return false;
}
return !!showPoints;
}
@@ -226,6 +232,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
show = true,
metric,
partialTimestamps,
facets,
} = this.props;
const resolvedLineColor = this.getLineColor();
@@ -255,6 +262,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
metric,
...(partialTimestamps?.size ? { partialTimestamps } : {}),
...(facets && { facets }),
};
}
}
@@ -270,6 +278,11 @@ interface PathBuilders {
/**
* Get path builder based on draw style and interpolation
*/
/** Draws standalone markers, never a connecting line. */
function isMarkerOnlyDrawStyle(drawStyle: DrawStyle): boolean {
return drawStyle === DrawStyle.Points || drawStyle === DrawStyle.Scatter;
}
function getPathBuilder({
drawStyle,
lineInterpolation,

View File

@@ -120,6 +120,41 @@ describe('UPlotAxisBuilder', () => {
});
});
it('draws the axis line by default, styled like the grid', () => {
const config = new UPlotAxisBuilder(
createAxisProps({ isDarkMode: true, grid: { width: 0.5 } }),
).getConfig();
expect(config.border).toStrictEqual({
show: true,
stroke: config.grid?.stroke,
width: 0.5,
});
});
it('leaves the axis line off when showBorder is false', () => {
expect(
new UPlotAxisBuilder(createAxisProps({ showBorder: false })).getConfig(),
).not.toHaveProperty('border');
});
it('leaves the axis line off when the grid is hidden', () => {
expect(
new UPlotAxisBuilder(createAxisProps({ grid: { show: false } })).getConfig(),
).not.toHaveProperty('border');
});
it('passes explicit splits and a label filter through to uPlot', () => {
const splits = jest.fn();
const filter = jest.fn();
const config = new UPlotAxisBuilder(
createAxisProps({ splits, filter }),
).getConfig();
expect(config.splits).toBe(splits);
expect(config.filter).toBe(filter);
});
it('uses provided ticks config when present and falls back to defaults otherwise', () => {
const customTicks = { width: 1, show: false };
const withTicks = new UPlotAxisBuilder(
@@ -376,3 +411,51 @@ describe('UPlotAxisBuilder', () => {
expect(config.values).toBeDefined();
});
});
describe('UPlotAxisBuilder value x axis', () => {
it('formats a non-time x axis with its unit', () => {
(getToolTipValue as jest.Mock).mockReturnValue('1.2K req/s');
const config = new UPlotAxisBuilder(
createAxisProps({ scaleKey: 'x', isTimeAxis: false, yAxisUnit: 'reqps' }),
).getConfig();
const values = (config.values as uPlot.Axis.DynamicValues)(
{} as uPlot,
[1200],
0,
0,
0,
);
expect(values).toStrictEqual(['1.2K req/s']);
expect(getToolTipValue).toHaveBeenCalledWith('1200', 'reqps', undefined);
});
it('leaves a non-time x axis to uPlot when nothing says how to format it', () => {
const config = new UPlotAxisBuilder(
createAxisProps({ scaleKey: 'x', isTimeAxis: false }),
).getConfig();
expect(config.values).toBeUndefined();
});
});
describe('UPlotAxisBuilder out-of-range splits', () => {
it('leaves a split the scale cannot place unlabelled', () => {
(getToolTipValue as jest.Mock).mockImplementation((v: string) => `${v} ms`);
const config = new UPlotAxisBuilder(
createAxisProps({ scaleKey: 'y', yAxisUnit: 'ms' }),
).getConfig();
const u = { scales: { y: { min: 0, max: 1000 } } } as unknown as uPlot;
const values = (config.values as uPlot.Axis.DynamicValues)(
u,
[-10, 0, 500, 5000],
0,
0,
0,
);
expect(values).toStrictEqual(['', '0 ms', '500 ms', '']);
});
});

View File

@@ -5,7 +5,12 @@ import {
STEP_INTERVAL_MULTIPLIER,
} from '../../constants';
import type { SeriesProps } from '../types';
import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types';
import {
DrawStyle,
PlotMode,
SelectionPreferencesSource,
StackMode,
} from '../types';
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
// Mock only the real boundary that hits localStorage
@@ -667,3 +672,14 @@ describe('UPlotConfigBuilder stacking', () => {
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
});
});
describe('UPlotConfigBuilder plot mode', () => {
it('leaves mode unset for aligned data and emits 2 when faceted', () => {
const aligned = new UPlotConfigBuilder({ id: 'aligned' });
expect(aligned.getConfig().mode).toBeUndefined();
const faceted = new UPlotConfigBuilder({ id: 'faceted' });
faceted.setMode(PlotMode.Faceted);
expect(faceted.getConfig().mode).toBe(2);
});
});

View File

@@ -425,3 +425,39 @@ describe('UPlotSeriesBuilder', () => {
);
});
});
describe('UPlotSeriesBuilder scatter', () => {
it('draws through the given path builder and hides uPlot points', () => {
const pathBuilder = jest.fn();
const config = new UPlotSeriesBuilder(
createBaseProps({
drawStyle: DrawStyle.Scatter,
pathBuilder,
facets: [{ scale: 'x' }, { scale: 'y' }],
lineColor: '#ff0000',
fillOpacity: 0.5,
lineWidth: 1,
pointSize: 8,
}),
).getConfig();
expect(config.paths).toBe(pathBuilder);
expect(config.facets).toStrictEqual([{ scale: 'x' }, { scale: 'y' }]);
expect(config.points?.show).toBe(false);
expect(config.points?.size).toBe(8);
expect(config.stroke).toBe('#ff0000');
expect(config.width).toBe(1);
expect(config.fill).toBe('#ff000080');
});
it('draws nothing without a path builder', () => {
const config = new UPlotSeriesBuilder(
createBaseProps({ drawStyle: DrawStyle.Scatter }),
).getConfig();
expect(
(config.paths as uPlot.Series.PathBuilder)({} as uPlot, 1, 0, 0),
).toBeNull();
expect(config.facets).toBeUndefined();
});
});

View File

@@ -80,6 +80,10 @@ export interface AxisProps {
values?: uPlot.Axis.Values;
/** Explicit axis splits, overriding the default tick calculation. */
splits?: uPlot.Axis.Splits;
/** Which splits get a label, replacing uPlot's per-distribution default. */
filter?: uPlot.Axis.Filter;
/** Draw the axis line along the plot edge, styled like the grid. Default true. */
showBorder?: boolean;
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
gap?: number;
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */
@@ -90,7 +94,7 @@ export interface AxisProps {
isDarkMode?: boolean;
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
isLogScale?: boolean;
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`). */
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`); a non-time x axis reads it too. */
yAxisUnit?: string;
/**
* X axis carries timestamps, so its ticks format as dates/times. Declared by the caller
@@ -109,6 +113,13 @@ export interface AxisProps {
export enum DistributionType {
Linear = 'linear',
Logarithmic = 'logarithmic',
/** arcsinh: linear within ±`asinhThreshold`, log beyond; places zero and negatives. */
SymmetricLog = 'symlog',
}
export enum PlotMode {
Aligned = 1,
Faceted = 2,
}
export interface ScaleProps {
@@ -125,6 +136,8 @@ export interface ScaleProps {
auto?: boolean;
logBase?: uPlot.Scale.LogBase;
distribution?: DistributionType;
/** Half-width of a `SymmetricLog` scale's linear band around zero. Default 1. */
asinhThreshold?: number;
}
export enum DisconnectedValuesMode {
@@ -146,6 +159,7 @@ export enum DrawStyle {
Points = 'points',
Bar = 'bar',
Histogram = 'histogram',
Scatter = 'scatter',
}
export enum LineInterpolation {
@@ -232,6 +246,8 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
metric?: { [key: string]: string };
/** Epoch-second x values whose bucket doesn't cover a full step; marked with a dotted ring. */
partialTimestamps?: Set<number>;
/** Mode 2 only: the scales the series' own x and y columns are read against. */
facets?: Series.Facet[];
}
export interface LegendItem {

View File

@@ -0,0 +1,116 @@
import type uPlot from 'uplot';
import { Quadtree } from '../../../utils/quadtree';
import {
resolveHit,
resolvePointDiameter,
resolveSizeDomain,
} from '../geometry';
import { ScatterHit, ScatterPointSize } from '../types';
const POINT_SIZE: ScatterPointSize = { fixed: 6, min: 4, max: 20 };
const asData = (columns: unknown[]): uPlot.AlignedData =>
columns as unknown as uPlot.AlignedData;
describe('resolveSizeDomain', () => {
it('spans the size columns of every series, skipping nulls', () => {
const data = asData([
null,
[
[1, 2],
[1, 2],
[10, null],
],
[[3], [3], [40]],
]);
expect(resolveSizeDomain(data)).toStrictEqual({ min: 10, max: 40 });
});
it('is null when no series carries sizes', () => {
expect(resolveSizeDomain(asData([null, [[1], [1]]]))).toBeNull();
expect(resolveSizeDomain(asData([null, [[1], [1], [null]]]))).toBeNull();
});
});
describe('resolvePointDiameter', () => {
it('uses the fixed diameter without a size or a domain', () => {
expect(resolvePointDiameter(null, { min: 0, max: 10 }, POINT_SIZE)).toBe(6);
expect(resolvePointDiameter(5, null, POINT_SIZE)).toBe(6);
});
it('maps the domain ends to min and max', () => {
const domain = { min: 0, max: 100 };
expect(resolvePointDiameter(0, domain, POINT_SIZE)).toBe(4);
expect(resolvePointDiameter(100, domain, POINT_SIZE)).toBe(20);
});
it('scales by area, not diameter', () => {
const midArea = (4 ** 2 + 20 ** 2) / 2;
expect(
resolvePointDiameter(50, { min: 0, max: 100 }, POINT_SIZE),
).toBeCloseTo(Math.sqrt(midArea));
});
it('clamps values outside the domain', () => {
const domain = { min: 10, max: 20 };
expect(resolvePointDiameter(-5, domain, POINT_SIZE)).toBe(4);
expect(resolvePointDiameter(500, domain, POINT_SIZE)).toBe(20);
});
it('uses the midpoint when every size is the same', () => {
expect(resolvePointDiameter(7, { min: 7, max: 7 }, POINT_SIZE)).toBe(12);
});
});
describe('resolveHit', () => {
const hit = (
seriesIndex: number,
dataIndex: number,
x: number,
y: number,
d: number,
): ScatterHit => ({ seriesIndex, dataIndex, x, y, w: d, h: d });
it('returns the disc under the cursor', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 6));
tree.add(hit(2, 3, 50, 50, 6));
expect(resolveHit(tree, 13, 13, 0)).toMatchObject({
seriesIndex: 1,
dataIndex: 0,
});
expect(resolveHit(tree, 52, 52, 0)).toMatchObject({
seriesIndex: 2,
dataIndex: 3,
});
});
it('is null when the cursor is off every disc', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 6));
expect(resolveHit(tree, 30, 30, 0)).toBeNull();
// Inside the disc's bounding square, outside the disc.
expect(resolveHit(tree, 15.8, 15.8, 0)).toBeNull();
});
it('tolerance widens each disc', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 6));
expect(resolveHit(tree, 18, 13, 0)).toBeNull();
expect(resolveHit(tree, 18, 13, 3)).not.toBeNull();
});
it('prefers the disc whose centre is nearest when they overlap', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 10));
tree.add(hit(1, 1, 14, 10, 10));
expect(resolveHit(tree, 13, 15, 0)?.dataIndex).toBe(0);
expect(resolveHit(tree, 21, 15, 0)?.dataIndex).toBe(1);
});
});

View File

@@ -0,0 +1,290 @@
import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../../../config/UPlotConfigBuilder';
import {
applyScatterPlugin,
createScatterPlugin,
SCATTER_FACETS,
} from '../scatterPlugin';
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
getStoredSeriesVisibility: jest.fn(),
}));
/** jsdom has no Path2D; the builder only needs something that takes the calls. */
class FakePath2D {
moveTo = jest.fn();
arc = jest.fn();
}
type OrientCallback = Parameters<typeof uPlot.orient>[2];
interface FakePlotArgs {
series: Array<{ xs: number[]; ys: number[]; sizes?: Array<number | null> }>;
cursor?: { left: number; top: number };
scaleX?: { min: number; max: number };
scaleY?: { min: number; max: number };
}
/**
* A 100×100 plot at the canvas origin with identity scales: value 10 draws at
* pixel 10 on x, and at 100 − 10 on y (uPlot's y grows downward).
*/
function createFakePlot({
series,
cursor = { left: -1, top: -1 },
scaleX = { min: 0, max: 100 },
scaleY = { min: 0, max: 100 },
}: FakePlotArgs): uPlot {
const data = [
null,
...series.map((entry) =>
entry.sizes ? [entry.xs, entry.ys, entry.sizes] : [entry.xs, entry.ys],
),
];
return {
data,
series: [{}, ...series.map((_, index) => ({ label: `s${index + 1}` }))],
bbox: { left: 0, top: 0, width: 100, height: 100 },
cursor,
scales: { x: scaleX, y: scaleY },
} as unknown as uPlot;
}
/** Stands in for `uPlot.orient`: identity x, flipped y, an `arc` that records. */
function orientWithIdentityScales(
u: uPlot,
seriesIdx: number,
cb: OrientCallback,
): void {
const columns = (u.data as unknown as Array<number[][] | null>)[seriesIdx];
if (!columns) {
return;
}
const scaleX = (u.scales as unknown as Record<string, uPlot.Scale>).x;
const scaleY = (u.scales as unknown as Record<string, uPlot.Scale>).y;
const valToPosX = (value: number): number => value;
const valToPosY = (value: number): number => 100 - value;
// Real uPlot's `arc` helper forwards to the path; the test counts those calls.
const arc = (path: FakePath2D, ...args: number[]): void => {
path.arc(...args);
};
cb(
u.series[seriesIdx],
columns[0],
columns[1],
scaleX,
scaleY,
valToPosX as unknown as uPlot.ValToPos,
valToPosY as unknown as uPlot.ValToPos,
0,
0,
100,
100,
jest.fn() as never,
jest.fn() as never,
jest.fn() as never,
arc as never,
jest.fn() as never,
);
}
describe('createScatterPlugin', () => {
beforeAll(() => {
(globalThis as { Path2D?: unknown }).Path2D = FakePath2D;
});
let orient: jest.SpyInstance;
beforeEach(() => {
orient = jest
.spyOn(uPlot, 'orient')
.mockImplementation(orientWithIdentityScales);
});
afterEach(() => {
orient.mockReset();
});
function drawAll(
u: uPlot,
plugin: ReturnType<typeof createScatterPlugin>,
): void {
plugin.hooks.drawClear(u);
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx++) {
const columns = (u.data as unknown as number[][][])[seriesIdx];
plugin.pathBuilder(u, seriesIdx, 0, columns[0].length - 1);
}
}
/** Runs the cursor scan the way uPlot does: every data series, in order. */
function scan(
u: uPlot,
plugin: ReturnType<typeof createScatterPlugin>,
): Array<number | null> {
const dataIdx = plugin.cursor.dataIdx as NonNullable<uPlot.Cursor['dataIdx']>;
const indexes: Array<number | null> = [null];
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx++) {
indexes.push(dataIdx(u, seriesIdx, 0, 0));
}
return indexes;
}
it('returns one path that strokes and fills the same discs', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({ series: [{ xs: [10, 20], ys: [10, 20] }] });
plugin.hooks.drawClear(u);
const paths = plugin.pathBuilder(u, 1, 0, 1) as uPlot.Series.Paths;
expect(paths.stroke).toBeInstanceOf(FakePath2D);
expect(paths.fill).toBe(paths.stroke);
expect((paths.fill as unknown as FakePath2D).arc).toHaveBeenCalledTimes(2);
});
it('resolves the hovered point to its own series and index', () => {
const plugin = createScatterPlugin({
pointSize: { fixed: 6, min: 4, max: 20 },
});
const u = createFakePlot({
series: [
{ xs: [10, 50], ys: [10, 50] },
{ xs: [80], ys: [80] },
],
// Over the second series' only point: x 80, y drawn at 100 − 80.
cursor: { left: 80, top: 20 },
});
drawAll(u, plugin);
expect(scan(u, plugin)).toStrictEqual([null, null, 0]);
expect(plugin.getHit()).toMatchObject({ seriesIndex: 2, dataIndex: 0 });
});
it('returns null for every series when the cursor is off the plot or off any disc', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({
series: [{ xs: [10], ys: [10] }],
cursor: { left: -1, top: -1 },
});
drawAll(u, plugin);
expect(scan(u, plugin)).toStrictEqual([null, null]);
(u.cursor as { left: number; top: number }).left = 50;
(u.cursor as { left: number; top: number }).top = 50;
expect(scan(u, plugin)).toStrictEqual([null, null]);
});
it('skips points outside the visible scale range', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({
series: [{ xs: [10, 500], ys: [10, 10] }],
cursor: { left: 10, top: 90 },
});
drawAll(u, plugin);
const paths = plugin.pathBuilder(u, 1, 0, 1) as uPlot.Series.Paths;
expect((paths.fill as unknown as FakePath2D).arc).toHaveBeenCalledTimes(1);
expect(scan(u, plugin)).toStrictEqual([null, 0]);
});
it('rings the hit disc, in CSS pixels, with a gap around it', () => {
const plugin = createScatterPlugin({
pointSize: { fixed: 8, min: 4, max: 20 },
});
const u = createFakePlot({
series: [{ xs: [10], ys: [10] }],
cursor: { left: 10, top: 90 },
});
drawAll(u, plugin);
scan(u, plugin);
const bbox = plugin.cursor.points?.bbox;
// The 8px disc spans 6..14; the ring sits 3px out and is 2px wide.
expect(bbox?.(u, 1)).toStrictEqual({
left: 1,
top: 81,
width: 18,
height: 18,
});
expect(bbox?.(u, 2)).toMatchObject({ width: 0, height: 0 });
});
it('snaps the ring to whole pixels without moving its centre', () => {
const plugin = createScatterPlugin({
pointSize: { fixed: 7, min: 4, max: 20 },
});
const u = createFakePlot({
series: [{ xs: [10], ys: [10] }],
cursor: { left: 10, top: 90 },
});
drawAll(u, plugin);
scan(u, plugin);
// The 7px disc starts at 6.5, so the ring's edge would land on 1.5.
const ring = plugin.cursor.points?.bbox?.(u, 1);
expect(ring).toMatchObject({ left: 1, width: 18 });
expect((ring?.left ?? 0) + (ring?.width ?? 0) / 2).toBe(10);
});
it('draws the hover marker as an outline in the series colour', () => {
const plugin = createScatterPlugin();
const u = {
series: [{}, { stroke: (): string => '#E5484D' }, { stroke: '#30A46C' }],
} as unknown as uPlot;
const points = plugin.cursor.points as {
fill: (u: uPlot, seriesIdx: number) => string;
stroke: (u: uPlot, seriesIdx: number) => string;
width: (u: uPlot, seriesIdx: number, size: number) => number;
};
expect(points.fill(u, 1)).toBe('transparent');
expect(points.stroke(u, 1)).toBe('#E5484D');
expect(points.stroke(u, 2)).toBe('#30A46C');
expect(points.width(u, 1, 0)).toBe(2);
});
it('drawClear drops cached paths on data series only', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({ series: [{ xs: [1], ys: [1] }] });
const [xSeries, dataSeries] = u.series as Array<{ _paths?: unknown }>;
xSeries._paths = 'x';
dataSeries._paths = 'cached';
plugin.hooks.drawClear(u);
expect(xSeries._paths).toBe('x');
expect(dataSeries._paths).toBeNull();
});
it('focus distance is zero, so the hit series wins focus', () => {
const plugin = createScatterPlugin();
expect(plugin.cursor.focus?.dist?.({} as uPlot, 1, 0, 0, 0)).toBe(0);
});
});
describe('applyScatterPlugin', () => {
it('switches the builder to faceted mode and disables drag selection', () => {
const builder = new UPlotConfigBuilder({ id: 'scatter' });
const plugin = createScatterPlugin();
applyScatterPlugin(builder, plugin);
const config = builder.getConfig();
expect(config.mode).toBe(2);
expect(config.cursor?.drag).toMatchObject({
x: false,
y: false,
setScale: false,
});
expect(config.hooks?.drawClear).toHaveLength(1);
expect(config.hooks?.destroy).toHaveLength(1);
});
it('facets read x and y against the shared scales', () => {
expect(SCATTER_FACETS).toStrictEqual([
{ scale: 'x', auto: true },
{ scale: 'y', auto: true },
]);
});
});

View File

@@ -0,0 +1,89 @@
import type uPlot from 'uplot';
import type { Quadtree } from '../../utils/quadtree';
import type { ScatterHit, ScatterPointSize, ScatterSeriesData } from './types';
export interface SizeDomain {
min: number;
max: number;
}
/** Size extent across all series, so equal values draw equal discs. */
export function resolveSizeDomain(data: uPlot.AlignedData): SizeDomain | null {
let min = Infinity;
let max = -Infinity;
for (let seriesIndex = 1; seriesIndex < data.length; seriesIndex++) {
const sizes = (data[seriesIndex] as unknown as ScatterSeriesData)[2];
if (!sizes) {
continue;
}
for (const size of sizes) {
if (size == null || !Number.isFinite(size)) {
continue;
}
min = Math.min(min, size);
max = Math.max(max, size);
}
}
return min <= max ? { min, max } : null;
}
/**
* Diameter for a size-channel value. Grows the disc's area, not its width,
* linearly with the value, so a value twice as large reads as twice the ink.
*/
export function resolvePointDiameter(
size: number | null | undefined,
domain: SizeDomain | null,
pointSize: ScatterPointSize,
): number {
if (size == null || domain == null || !Number.isFinite(size)) {
return pointSize.fixed;
}
if (domain.max === domain.min) {
return (pointSize.min + pointSize.max) / 2;
}
const t = Math.min(
1,
Math.max(0, (size - domain.min) / (domain.max - domain.min)),
);
const minArea = pointSize.min ** 2;
const maxArea = pointSize.max ** 2;
return Math.sqrt(minArea + (maxArea - minArea) * t);
}
/**
* The disc under the cursor, counting `tolerance` px beyond its edge as a hit.
* Where discs overlap, the one whose centre is closest wins; `null` if none.
*/
export function resolveHit(
tree: Quadtree<ScatterHit>,
cx: number,
cy: number,
tolerance: number,
): ScatterHit | null {
let best: ScatterHit | null = null;
let bestDistance = Infinity;
tree.get(
cx - tolerance,
cy - tolerance,
tolerance * 2,
tolerance * 2,
(hit) => {
const dx = cx - (hit.x + hit.w / 2);
const dy = cy - (hit.y + hit.h / 2);
const distance = dx * dx + dy * dy;
const reach = hit.w / 2 + tolerance;
if (distance > reach * reach) {
return;
}
if (distance < bestDistance) {
best = hit;
bestDistance = distance;
}
},
);
return best;
}

View File

@@ -0,0 +1,225 @@
import uPlot, { Series } from 'uplot';
import { DEFAULT_FOCUS_PROXIMITY_VALUE } from '../../constants';
import { PlotMode } from '../../config/types';
import type { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
import { Quadtree } from '../../utils/quadtree';
import {
resolveHit,
resolvePointDiameter,
resolveSizeDomain,
SizeDomain,
} from './geometry';
import {
DEFAULT_HOVER_TOLERANCE_PX,
DEFAULT_SCATTER_POINT_SIZE,
ScatterHit,
ScatterPluginOptions,
ScatterSeriesData,
} from './types';
export const SCATTER_FACETS: Series.Facet[] = [
{ scale: 'x', auto: true },
{ scale: 'y', auto: true },
];
const HIDDEN_BBOX: uPlot.BBox = { left: -10, top: -10, width: 0, height: 0 };
const TWO_PI = 2 * Math.PI;
/** CSS pixels between a hovered disc and the outline ring drawn around it. */
const HOVER_RING_GAP_PX = 3;
const HOVER_RING_WIDTH_PX = 2;
/** uPlot ceils a cursor point's left/top but not its size; snap so the ring stays centred. */
function snapToPixel(start: number, length: number): [number, number] {
const snapped = Math.floor(start);
return [snapped, length + (start - snapped) * 2];
}
export function getSeriesStroke(u: uPlot, seriesIdx: number): string {
const { stroke } = u.series[seriesIdx];
const color = typeof stroke === 'function' ? stroke(u, seriesIdx) : stroke;
return typeof color === 'string' ? color : '';
}
/** uPlot caches built paths on the series; the field is internal to it. */
type SeriesWithPaths = Series & { _paths?: Series.Paths | null };
export interface ScatterPlugin {
pathBuilder: Series.PathBuilder;
/** Hover by disc rather than by nearest x: mode 2 has no shared x to scan. */
cursor: uPlot.Cursor;
hooks: {
drawClear: (u: uPlot) => void;
destroy: (u: uPlot) => void;
};
getHit: () => ScatterHit | null;
}
export function createScatterPlugin({
pointSize = DEFAULT_SCATTER_POINT_SIZE,
hoverTolerance = DEFAULT_HOVER_TOLERANCE_PX,
}: ScatterPluginOptions = {}): ScatterPlugin {
let tree: Quadtree<ScatterHit> | null = null;
let hit: ScatterHit | null = null;
// Spans every series; cached per dataset.
let cachedData: uPlot.AlignedData | null = null;
let cachedDomain: SizeDomain | null = null;
function getSizeDomain(u: uPlot): SizeDomain | null {
if (cachedData !== u.data) {
cachedDomain = resolveSizeDomain(u.data);
cachedData = u.data;
}
return cachedDomain;
}
const pathBuilder: Series.PathBuilder = (u, seriesIdx, idx0, idx1) => {
const path = new Path2D();
const sizes = (u.data[seriesIdx] as unknown as ScatterSeriesData)[2];
const domain = getSizeDomain(u);
const { pxRatio } = uPlot;
uPlot.orient(
u,
seriesIdx,
(
_series,
dataX,
dataY,
scaleX,
scaleY,
valToPosX,
valToPosY,
xOff,
yOff,
xDim,
yDim,
_moveTo,
_lineTo,
_rect,
arc,
) => {
const xMin = scaleX.min ?? -Infinity;
const xMax = scaleX.max ?? Infinity;
const yMin = scaleY.min ?? -Infinity;
const yMax = scaleY.max ?? Infinity;
for (let i = idx0; i <= idx1; i++) {
const x = dataX[i];
const y = dataY[i];
if (
x == null ||
y == null ||
x < xMin ||
x > xMax ||
y < yMin ||
y > yMax
) {
continue;
}
const diameter =
resolvePointDiameter(sizes?.[i], domain, pointSize) * pxRatio;
const radius = diameter / 2;
const cx = valToPosX(x, scaleX, xDim, xOff);
const cy = valToPosY(y, scaleY, yDim, yOff);
path.moveTo(cx + radius, cy);
arc(path, cx, cy, radius, 0, TWO_PI);
tree?.add({
x: cx - radius - u.bbox.left,
y: cy - radius - u.bbox.top,
w: diameter,
h: diameter,
seriesIndex: seriesIdx,
dataIndex: i,
});
}
},
);
return { stroke: path, fill: path, clip: null };
};
const cursor: uPlot.Cursor = {
// Selection would set the dashboard time range; neither axis is time here.
drag: { x: false, y: false, setScale: false },
dataIdx: (u, seriesIdx): number | null => {
// uPlot asks series 1..n in order on every cursor move; resolve once.
if (seriesIdx === 1) {
const { left = -1, top = -1 } = u.cursor;
const { pxRatio } = uPlot;
hit =
tree && left >= 0 && top >= 0
? resolveHit(
tree,
left * pxRatio,
top * pxRatio,
hoverTolerance * pxRatio,
)
: null;
}
return hit?.seriesIndex === seriesIdx ? hit.dataIndex : null;
},
points: {
bbox: (_u, seriesIdx): uPlot.BBox => {
if (hit?.seriesIndex !== seriesIdx) {
return HIDDEN_BBOX;
}
const { pxRatio } = uPlot;
const inset = HOVER_RING_GAP_PX + HOVER_RING_WIDTH_PX;
const [left, width] = snapToPixel(
hit.x / pxRatio - inset,
hit.w / pxRatio + inset * 2,
);
const [top, height] = snapToPixel(
hit.y / pxRatio - inset,
hit.h / pxRatio + inset * 2,
);
return { left, top, width, height };
},
fill: (): string => 'transparent',
stroke: getSeriesStroke,
width: (): number => HOVER_RING_WIDTH_PX,
},
// uPlot only measures series that returned a data index, i.e. the hit one.
focus: { prox: DEFAULT_FOCUS_PROXIMITY_VALUE, dist: (): number => 0 },
};
return {
pathBuilder,
cursor,
hooks: {
drawClear: (u: uPlot): void => {
tree = new Quadtree<ScatterHit>(0, 0, u.bbox.width, u.bbox.height);
// The tree holds only what the path builder drew, so force a rebuild.
u.series.forEach((series, index) => {
if (index > 0) {
(series as SeriesWithPaths)._paths = null;
}
});
},
destroy: (): void => {
tree = null;
hit = null;
cachedData = null;
cachedDomain = null;
},
},
getHit: (): ScatterHit | null => hit,
};
}
export function applyScatterPlugin(
builder: UPlotConfigBuilder,
plugin: ScatterPlugin,
): void {
builder.setMode(PlotMode.Faceted);
builder.setCursor(plugin.cursor);
builder.addHook('drawClear', plugin.hooks.drawClear);
builder.addHook('destroy', plugin.hooks.destroy);
}

View File

@@ -0,0 +1,54 @@
import type { QuadtreeRect } from '../../utils/quadtree';
/** Diameters in CSS pixels. `min`/`max` bound the area scale when a size column is mapped. */
export interface ScatterPointSize {
fixed: number;
min: number;
max: number;
}
export const DEFAULT_SCATTER_POINT_SIZE: ScatterPointSize = {
fixed: 6,
min: 4,
max: 24,
};
/** CSS pixels around a point's disc that still register as a hover. */
export const DEFAULT_HOVER_TOLERANCE_PX = 3;
export interface ScatterPluginOptions {
pointSize?: ScatterPointSize;
hoverTolerance?: number;
}
/** Parallel columns; a `null` size draws at `fixed`. */
export type ScatterSeriesData = [
xs: number[],
ys: number[],
sizes?: Array<number | null>,
];
/** Mode-2 data: series 0 is uPlot's x placeholder and carries nothing. */
export type ScatterChartData = [null, ...ScatterSeriesData[]];
/** A drawn point's disc, in canvas pixels relative to the plot area. */
export interface ScatterHit extends QuadtreeRect {
seriesIndex: number;
dataIndex: number;
}
export interface ScatterChannel {
label: string;
unit?: string;
}
export interface ScatterChannels {
x: ScatterChannel;
y: ScatterChannel;
size?: ScatterChannel;
}
export interface ScatterPointLabel {
key: string;
value: string;
}

View File

@@ -0,0 +1,52 @@
import type uPlot from 'uplot';
import { buildClickData } from '../utils';
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
getFocusedSeriesAtPosition: jest.fn(() => ({ seriesIndex: 1 })),
}));
const event = {
offsetX: 10,
offsetY: 20,
clientX: 110,
clientY: 220,
} as MouseEvent;
describe('buildClickData', () => {
it('skips the x-index lookups on a faceted plot, which has no shared x series', () => {
const posToIdx = jest.fn(() => {
throw new Error('faceted data[0] is null');
});
const plot = {
data: [null],
posToVal: (pos: number): number => pos * 2,
posToIdx,
} as unknown as uPlot;
expect(buildClickData(event, plot)).toStrictEqual({
xValue: 20,
yValue: 40,
focusedSeries: null,
clickedDataTimestamp: 20,
mouseX: 10,
mouseY: 20,
absoluteMouseX: 110,
absoluteMouseY: 220,
});
expect(posToIdx).not.toHaveBeenCalled();
});
it('resolves the focused series and clicked timestamp on an aligned plot', () => {
const plot = {
data: [[100, 200, 300]],
posToVal: (pos: number): number => pos,
posToIdx: (): number => 1,
} as unknown as uPlot;
expect(buildClickData(event, plot)).toMatchObject({
focusedSeries: { seriesIndex: 1 },
clickedDataTimestamp: 200,
});
});
});

View File

@@ -174,6 +174,24 @@ export function createLayoutObserver(
export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
const xValue = plot.posToVal(event.offsetX, 'x');
const yValue = plot.posToVal(event.offsetY, 'y');
const pointer = {
mouseX: event.offsetX,
mouseY: event.offsetY,
absoluteMouseX: event.clientX,
absoluteMouseY: event.clientY,
};
// Faceted (scatter): no shared x, so `posToIdx` would throw.
if (plot.data[0] == null) {
return {
xValue,
yValue,
focusedSeries: null,
clickedDataTimestamp: xValue,
...pointer,
};
}
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
const dataIndex = plot.posToIdx(event.offsetX);
@@ -193,9 +211,6 @@ export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
yValue,
focusedSeries,
clickedDataTimestamp,
mouseX: event.offsetX,
mouseY: event.offsetY,
absoluteMouseX: event.clientX,
absoluteMouseY: event.clientY,
...pointer,
};
}

View File

@@ -0,0 +1,66 @@
import type uPlot from 'uplot';
import { logScaleSplits, spacedLogLabels } from '../logGridSplits';
describe('logScaleSplits', () => {
const splitsFor = (min: number, max: number): number[] =>
(
logScaleSplits as (
u: uPlot,
axisIdx: number,
min: number,
max: number,
) => number[]
)({} as uPlot, 0, min, max);
it('splits at 1, 2 and 5 × 10ⁿ inside an unsnapped range', () => {
expect(splitsFor(10.6, 990)).toStrictEqual([20, 50, 100, 200, 500]);
});
it('includes the ends when they fall on a split', () => {
expect(splitsFor(0.1, 10)).toStrictEqual([0.1, 0.2, 0.5, 1, 2, 5, 10]);
});
it('steps evenly through a range too narrow for two 1/2/5 splits', () => {
expect(splitsFor(7.6, 9.4)).toStrictEqual([8, 9]);
expect(splitsFor(8.12, 8.19)).toStrictEqual([
8.12, 8.13, 8.14, 8.15, 8.16, 8.17, 8.18, 8.19,
]);
});
it('is empty for a range a log scale cannot hold', () => {
expect(splitsFor(0, 10)).toStrictEqual([]);
expect(splitsFor(5, 5)).toStrictEqual([]);
});
});
describe('spacedLogLabels', () => {
// 100px per decade.
const plot = {
axes: [{ scale: 'x' }],
valToPos: (value: number): number => Math.log10(value) * 100,
} as unknown as uPlot;
const label = (splits: number[], space: number): (number | null)[] =>
spacedLogLabels(plot, splits, 0, space, 0) as (number | null)[];
it('always labels the powers of ten', () => {
expect(label([10, 20, 50, 100], 200)).toStrictEqual([10, null, null, 100]);
});
it('adds a 2 or 5 that clears the labels already kept', () => {
// 20 is 30px from 10; 50 is 70px from 10 and 30px from 100.
expect(label([10, 20, 50, 100], 25)).toStrictEqual([10, 20, 50, 100]);
expect(label([10, 20, 50, 100], 40)).toStrictEqual([10, null, null, 100]);
});
it('labels both ends of a range spanning under a decade', () => {
// 100 sits at 200px; 20 (130px) and 500 (270px) clear it, 50 and 200 do not.
expect(label([20, 50, 100, 200, 500], 60)).toStrictEqual([
20,
null,
100,
null,
500,
]);
});
});

View File

@@ -0,0 +1,64 @@
import { Quadtree, QuadtreeRect } from '../quadtree';
interface Item extends QuadtreeRect {
id: number;
}
function collect(
tree: Quadtree<Item>,
x: number,
y: number,
w: number,
h: number,
): Set<number> {
const ids = new Set<number>();
tree.get(x, y, w, h, (item) => ids.add(item.id));
return ids;
}
describe('Quadtree', () => {
it('returns items in the queried region and not those far from it', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
tree.add({ id: 1, x: 10, y: 10, w: 5, h: 5 });
tree.add({ id: 2, x: 80, y: 80, w: 5, h: 5 });
// Below the split threshold every item is visited; callers refine the hit.
expect(collect(tree, 9, 9, 8, 8)).toStrictEqual(new Set([1, 2]));
});
it('splits past the object limit and still finds every item', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
const total = 50;
for (let id = 0; id < total; id++) {
tree.add({ id, x: (id % 10) * 10, y: Math.floor(id / 10) * 10, w: 4, h: 4 });
}
expect(collect(tree, 0, 0, 100, 100).size).toBe(total);
});
it('after a split, a query in one quadrant skips items confined to another', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
for (let id = 0; id < 20; id++) {
// All in the north-west quadrant.
tree.add({ id, x: 1 + id, y: 1, w: 2, h: 2 });
}
tree.add({ id: 99, x: 90, y: 90, w: 2, h: 2 });
const northWest = collect(tree, 0, 0, 10, 10);
expect(northWest.has(99)).toBe(false);
expect(collect(tree, 85, 85, 10, 10).has(99)).toBe(true);
});
it('reports an item straddling the midline from either side', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
for (let id = 0; id < 20; id++) {
tree.add({ id, x: 1, y: 1 + id, w: 2, h: 2 });
}
tree.add({ id: 99, x: 48, y: 48, w: 4, h: 4 });
expect(collect(tree, 40, 40, 5, 5).has(99)).toBe(true);
expect(collect(tree, 55, 55, 5, 5).has(99)).toBe(true);
// A zero-size query exactly on both midlines.
expect(collect(tree, 50, 50, 0, 0).has(99)).toBe(true);
});
});

View File

@@ -195,3 +195,22 @@ describe('scale utils', () => {
});
});
});
describe('symmetric log scale', () => {
it('maps to uPlot arcsinh with the given linear threshold', () => {
expect(
scaleUtils.getDistributionConfig({
time: false,
distr: DistributionType.SymmetricLog,
asinhThreshold: 0.01,
}),
).toStrictEqual({ distr: 4, log: 10, asinh: 0.01 });
expect(
scaleUtils.getDistributionConfig({
time: false,
distr: DistributionType.SymmetricLog,
}).asinh,
).toBe(scaleUtils.DEFAULT_ASINH_THRESHOLD);
});
});

View File

@@ -0,0 +1,92 @@
import type uPlot from 'uplot';
const DECADE_TOLERANCE = 1e-9;
function isDecade(value: number): boolean {
if (value === 0) {
return true;
}
const exponent = Math.log10(Math.abs(value));
return Math.abs(exponent - Math.round(exponent)) < DECADE_TOLERANCE;
}
const LOG_SPLIT_MULTIPLES = [1, 2, 5];
/** Decades finer than the range's own span that the fallback steps through. */
const FALLBACK_STEP_DECADES = 3;
/** Rounded through toPrecision so 0.1 × 3 noise can't fall outside [min, max]. */
function roundSplit(value: number): number {
return Number(value.toPrecision(12));
}
/** Evenly stepped splits for a range too narrow to hold two 1/2/5 splits. */
function narrowRangeSplits(min: number, max: number): number[] {
const coarsest = Math.floor(Math.log10(max - min));
for (
let exponent = coarsest;
exponent > coarsest - FALLBACK_STEP_DECADES;
exponent--
) {
const step = 10 ** exponent;
const first = Math.ceil(roundSplit(min / step));
const last = Math.floor(roundSplit(max / step));
if (last > first) {
return Array.from({ length: last - first + 1 }, (_, index) =>
roundSplit((first + index) * step),
);
}
}
return [];
}
/** 1, 2, 5 × 10ⁿ in range; uPlot's log splits step from an unsnapped min (10.79, 21.58…). */
export const logScaleSplits: uPlot.Axis.Splits = (_u, _axisIdx, min, max) => {
if (!(min > 0) || !(max > min)) {
return [];
}
const splits: number[] = [];
for (
let exponent = Math.floor(Math.log10(min));
exponent <= Math.ceil(Math.log10(max));
exponent++
) {
LOG_SPLIT_MULTIPLES.forEach((multiple) => {
const split = roundSplit(multiple * 10 ** exponent);
if (split >= min && split <= max) {
splits.push(split);
}
});
}
if (splits.length >= 2) {
return splits;
}
const narrow = narrowRangeSplits(min, max);
return narrow.length > splits.length ? narrow : splits;
};
/** Labels every decade, plus any 2 or 5 at least `space` px clear. */
export const spacedLogLabels: uPlot.Axis.Filter = (
u,
splits,
axisIdx,
space,
) => {
const scaleKey = u.axes[axisIdx]?.scale ?? 'x';
const positions = splits.map((split) =>
split == null ? null : u.valToPos(split, scaleKey),
);
const kept = splits.map((split) => split != null && isDecade(split));
const isClear = (index: number): boolean =>
kept.every(
(isKept, other) =>
!isKept ||
Math.abs((positions[other] ?? 0) - (positions[index] ?? 0)) >= space,
);
splits.forEach((split, index) => {
if (split != null && !kept[index] && isClear(index)) {
kept[index] = true;
}
});
return splits.map((split, index) => (kept[index] ? split : null));
};

View File

@@ -0,0 +1,101 @@
export interface QuadtreeRect {
x: number;
y: number;
w: number;
h: number;
}
const MAX_OBJECTS = 10;
const MAX_LEVELS = 4;
/** Rect index for cursor hit-testing; items straddling quadrants are stored in each, so `get` may repeat them. */
export class Quadtree<T extends QuadtreeRect = QuadtreeRect> {
private items: T[] = [];
private quadrants: Quadtree<T>[] | null = null;
constructor(
private readonly x: number,
private readonly y: number,
private readonly w: number,
private readonly h: number,
private readonly level = 0,
) {}
add(item: T): void {
if (this.quadrants) {
this.forEachQuadrant(item, (quadrant) => quadrant.add(item));
return;
}
this.items.push(item);
if (this.items.length > MAX_OBJECTS && this.level < MAX_LEVELS) {
this.split();
const items = this.items;
this.items = [];
for (const existing of items) {
this.forEachQuadrant(existing, (quadrant) => quadrant.add(existing));
}
}
}
/** Visits every item whose quadrant overlaps the rectangle; callers refine the test. */
get(
x: number,
y: number,
w: number,
h: number,
visit: (item: T) => void,
): void {
for (const item of this.items) {
visit(item);
}
if (this.quadrants) {
this.forEachQuadrant({ x, y, w, h }, (quadrant) =>
quadrant.get(x, y, w, h, visit),
);
}
}
private split(): void {
const w = this.w / 2;
const h = this.h / 2;
const level = this.level + 1;
// North-east, north-west, south-west, south-east.
this.quadrants = [
new Quadtree<T>(this.x + w, this.y, w, h, level),
new Quadtree<T>(this.x, this.y, w, h, level),
new Quadtree<T>(this.x, this.y + h, w, h, level),
new Quadtree<T>(this.x + w, this.y + h, w, h, level),
];
}
private forEachQuadrant(
rect: QuadtreeRect,
visit: (quadrant: Quadtree<T>) => void,
): void {
if (!this.quadrants) {
return;
}
const midX = this.x + this.w / 2;
const midY = this.y + this.h / 2;
const startsNorth = rect.y < midY;
const startsWest = rect.x < midX;
// Inclusive, so a zero-size query on a midline still reaches a quadrant.
const endsEast = rect.x + rect.w >= midX;
const endsSouth = rect.y + rect.h >= midY;
if (startsNorth && endsEast) {
visit(this.quadrants[0]);
}
if (startsWest && startsNorth) {
visit(this.quadrants[1]);
}
if (startsWest && endsSouth) {
visit(this.quadrants[2]);
}
if (endsEast && endsSouth) {
visit(this.quadrants[3]);
}
}
}

View File

@@ -58,18 +58,23 @@ function normalizeLogLimit(
return logBase ** exp;
}
export const DEFAULT_ASINH_THRESHOLD = 1;
/**
* Returns uPlot scale distribution options for the Y axis.
* Time (X) scale gets no distr/log; Y scale gets distr 1 (linear) or 3 (log) and log base 2 or 10.
* Returns uPlot scale distribution options for a value axis.
* Time scales get no distr/log; value scales get distr 1 (linear), 3 (log) or
* 4 (arcsinh, uPlot's symmetric log) and log base 2 or 10.
*/
export function getDistributionConfig({
time,
distr,
logBase,
asinhThreshold,
}: {
time: ScaleProps['time'];
distr?: DistributionType;
logBase?: number;
asinhThreshold?: number;
}): Partial<Scale> {
if (time) {
return {};
@@ -77,6 +82,14 @@ export function getDistributionConfig({
const resolvedLogBase = (logBase ?? 10) === 2 ? 2 : 10;
if (distr === DistributionType.SymmetricLog) {
return {
distr: 4,
log: resolvedLogBase,
asinh: asinhThreshold ?? DEFAULT_ASINH_THRESHOLD,
};
}
return {
distr: distr === DistributionType.Logarithmic ? 3 : 1,
log: resolvedLogBase,

View File

@@ -37,6 +37,7 @@ export default function ChartWrapper({
syncKey,
syncFilterMode,
onDestroy = noop,
plotRef,
children,
layoutChildren,
yAxisUnit,
@@ -123,6 +124,7 @@ export default function ChartWrapper({
height={chartHeight}
plotRef={(plot): void => {
plotInstanceRef.current = plot;
plotRef?.(plot);
}}
onDestroy={(plot: uPlot): void => {
plotInstanceRef.current = null;

View File

@@ -0,0 +1,45 @@
import { render } from 'tests/test-utils';
import type uPlot from 'uplot';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import ChartWrapper from '../ChartWrapper';
const FAKE_PLOT = { id: 'plot' } as unknown as uPlot;
jest.mock('lib/uPlotV2/components/UPlotChart/UPlotChart', () => ({
__esModule: true,
default: ({ plotRef }: { plotRef?: (plot: uPlot | null) => void }): null => {
plotRef?.(FAKE_PLOT);
return null;
},
}));
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('ChartWrapper', () => {
it('hands the plot instance to the caller', () => {
const plotRef = jest.fn();
render(
<ChartWrapper
config={new UPlotConfigBuilder({ id: 'chart' })}
data={[[1], [2]]}
width={400}
height={300}
legendConfig={{ position: LegendPosition.BOTTOM }}
showLegend={false}
showTooltip={false}
plotRef={plotRef}
/>,
);
expect(plotRef).toHaveBeenCalledWith(FAKE_PLOT);
});
});

View File

@@ -0,0 +1,240 @@
import { useMemo } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import type { Threshold } from 'lib/uPlotV2/hooks/types';
import type { ScatterPointLabel } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import Scatter from './Scatter';
import { ScatterAxisScale, type ScatterSeries } from './types';
import { buildScatterConfig, prepareScatterChartData } from './utils';
const SERVICES = [
'frontend',
'cart',
'checkout',
'payment',
'shipping',
'currency',
'email',
'recommendation',
'ads',
'product-catalog',
];
type Shape = 'spread' | 'single' | 'sameX';
interface ScatterStoryProps {
groups: number;
pointsPerGroup: number;
/** Adds an error-count size column. */
sized: boolean;
xScale: ScatterAxisScale;
yScale: ScatterAxisScale;
/** Zeroes a share of y values, which forces the symmetric log. */
withZeros: boolean;
shape: Shape;
thresholds: boolean;
pointSize: number;
/** 0–1. */
fillOpacity: number;
width: number;
height: number;
}
/** Deterministic, so a story renders the same points on every run. */
function createRng(seed: number): () => number {
let state = seed >>> 0;
return (): number => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 2 ** 32;
};
}
function buildSeries({
groups,
pointsPerGroup,
sized,
withZeros,
shape,
}: ScatterStoryProps): ScatterSeries[] {
const rng = createRng(42);
return Array.from({ length: groups }, (_, groupIndex) => {
const label = SERVICES[groupIndex % SERVICES.length];
// Each service sits in its own throughput/latency band, so groups are telling
// apart rather than one cloud.
const baseThroughput = 20 * 2 ** (groupIndex % 5);
const baseLatency = 40 + 60 * (groupIndex % 4);
const count = shape === 'single' ? 1 : pointsPerGroup;
const xs: number[] = [];
const ys: number[] = [];
const sizes: Array<number | null> = [];
for (let i = 0; i < count; i++) {
const throughput =
shape === 'sameX' ? baseThroughput : baseThroughput * (0.5 + rng() * 1.5);
// Latency grows with load, plus noise; the odd outlier keeps the axis honest.
const outlier = rng() < 0.03 ? 4 + rng() * 6 : 1;
let latency =
baseLatency *
(0.8 + (throughput / baseThroughput) * 0.4 + rng() * 0.3) *
outlier;
if (withZeros && rng() < 0.2) {
latency = 0;
}
xs.push(Number(throughput.toFixed(2)));
ys.push(Number(latency.toFixed(2)));
sizes.push(rng() < 0.1 ? null : Math.round(rng() * rng() * 500));
}
return sized ? { label, xs, ys, sizes } : { label, xs, ys };
});
}
const THRESHOLDS: Threshold[] = [
{
thresholdValue: 300,
thresholdUnit: 'ms',
thresholdColor: '#E5484D',
thresholdLabel: 'p99 SLO',
},
];
function ScatterStory(props: ScatterStoryProps): JSX.Element {
const {
xScale,
yScale,
sized,
thresholds,
pointSize,
fillOpacity,
width,
height,
} = props;
const isDarkMode = useIsDarkMode();
const series = useMemo(() => buildSeries(props), [props]);
const config = useMemo(
() =>
buildScatterConfig({
id: 'scatter-story',
series,
isDarkMode,
x: { unit: 'reqps', scale: xScale },
y: { unit: 'ms', scale: yScale },
pointSize: { fixed: pointSize, min: 4, max: pointSize * 4 },
fillOpacity,
thresholds: thresholds ? THRESHOLDS : undefined,
}),
[series, isDarkMode, xScale, yScale, pointSize, fillOpacity, thresholds],
);
const data = useMemo(() => prepareScatterChartData(series), [series]);
const resolvePointGroupValues = (
seriesIndex: number,
dataIndex: number,
): ScatterPointLabel[] => [
{ key: 'service.name', value: series[seriesIndex - 1]?.label ?? '' },
{
key: 'k8s.pod.name',
value: `pod-${dataIndex.toString().padStart(3, '0')}`,
},
];
return (
<div style={{ width, padding: 16 }}>
<Scatter
config={config}
data={data}
width={width}
height={height}
legendConfig={{ position: LegendPosition.BOTTOM }}
channels={{
x: { label: 'Throughput', unit: 'reqps' },
y: { label: 'p99 latency', unit: 'ms' },
...(sized && { size: { label: 'Errors', unit: 'short' } }),
}}
resolvePointGroupValues={resolvePointGroupValues}
canPinTooltip
/>
</div>
);
}
const meta = {
title: 'Charts/Scatter',
component: ScatterStory,
parameters: { layout: 'padded' },
args: {
groups: 1,
pointsPerGroup: 10,
sized: false,
xScale: ScatterAxisScale.Linear,
yScale: ScatterAxisScale.Linear,
withZeros: false,
shape: 'spread',
thresholds: false,
pointSize: 6,
fillOpacity: 0.7,
width: 800,
height: 420,
},
argTypes: {
shape: { control: 'radio', options: ['spread', 'single', 'sameX'] },
xScale: { control: 'radio', options: Object.values(ScatterAxisScale) },
yScale: { control: 'radio', options: Object.values(ScatterAxisScale) },
fillOpacity: { control: { type: 'range', min: 0, max: 1, step: 0.05 } },
pointSize: { control: { type: 'range', min: 2, max: 16, step: 1 } },
},
} satisfies Meta<ScatterStoryProps>;
export default meta;
type Story = StoryObj<ScatterStoryProps>;
/** One service, ten points: axes formatted with units, hover picks the right point. */
export const Basic: Story = {};
/** Five services, one legend entry each; toggling a row hides its points. */
export const Grouped: Story = {
args: { groups: 5, pointsPerGroup: 40 },
};
/** Error count as disc area, between the configured min and max diameters. */
export const Sized: Story = {
args: { groups: 5, pointsPerGroup: 40, sized: true, pointSize: 5 },
};
/** Log x; a fifth of the latencies are 0, so y falls back to the symmetric log. */
export const LogAxes: Story = {
args: {
groups: 5,
pointsPerGroup: 60,
xScale: ScatterAxisScale.Log,
yScale: ScatterAxisScale.Log,
withZeros: true,
},
};
/** A single point still gets a padded range rather than an empty plot. */
export const SinglePoint: Story = {
args: { shape: 'single' },
};
/** Fifty points sharing one x collide on nothing: no shared x array to align. */
export const SameX: Story = {
args: { groups: 3, pointsPerGroup: 50, shape: 'sameX' },
};
/** Horizontal line with label on the y axis; the scale stretches to include it. */
export const Thresholds: Story = {
args: { groups: 3, pointsPerGroup: 40, thresholds: true },
};
/** Perf harness: raise `pointsPerGroup` and read the draw time under the chart. */
export const Dense: Story = {
args: { groups: 5, pointsPerGroup: 1000, pointSize: 4, fillOpacity: 0.5 },
};

View File

@@ -0,0 +1,98 @@
import { useCallback, useRef } from 'react';
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
import ScatterTooltip from 'lib/uPlotV2/components/Tooltip/ScatterTooltip';
import {
ScatterTooltipProps,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { getSeriesStroke } from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { ScatterChartProps } from 'lib/visualization/charts/types';
import uPlot from 'uplot';
import { getCursorHit } from './utils';
// Faceted uPlot can't mount without series; empty aligned data shows no-data instead.
const EMPTY_ALIGNED_DATA: uPlot.AlignedData = [[]];
export default function Scatter(props: ScatterChartProps): JSX.Element {
const {
children,
customTooltip,
channels,
resolvePointGroupValues,
pinnedTooltipElement,
onPointClick,
plotRef,
...rest
} = props;
const plotInstanceRef = useRef<uPlot | null>(null);
const handlePlotRef = useCallback(
(plot: uPlot | null): void => {
plotInstanceRef.current = plot;
plotRef?.(plot);
},
[plotRef],
);
// Faceted plots have no shared x; take the hit from the cursor.
const handleClick = useCallback(
(click: ChartClickData): void => {
const plot = plotInstanceRef.current;
const hit = plot ? getCursorHit(plot) : null;
if (!plot || !hit || !onPointClick) {
return;
}
onPointClick({
...hit,
color: getSeriesStroke(plot, hit.seriesIndex),
coordinates: { x: click.absoluteMouseX, y: click.absoluteMouseY },
});
},
[onPointClick],
);
const renderTooltip = useCallback(
(args: TooltipRenderArgs): React.ReactNode => {
if (customTooltip) {
return customTooltip(args);
}
const tooltipProps: ScatterTooltipProps = {
...args,
id: rest.config.getId(),
channels,
resolvePointGroupValues,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <ScatterTooltip {...tooltipProps} />;
},
[
customTooltip,
channels,
resolvePointGroupValues,
rest.config,
rest.decimalPrecision,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
);
const hasSeries = rest.data.length > 1;
return (
<ChartWrapper
{...rest}
data={hasSeries ? rest.data : EMPTY_ALIGNED_DATA}
plotRef={handlePlotRef}
onClick={onPointClick ? handleClick : rest.onClick}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>
{children}
</ChartWrapper>
);
}

View File

@@ -0,0 +1,112 @@
import { DistributionType } from 'lib/uPlotV2/config/types';
import { resolveAxisDistribution } from '../axisDistribution';
import { ScatterAxisScale } from '../types';
describe('resolveAxisDistribution', () => {
it('is linear when asked, whatever the values', () => {
expect(
resolveAxisDistribution([1, 1e6], ScatterAxisScale.Linear),
).toStrictEqual({ distribution: DistributionType.Linear });
});
it('is a plain log when every value is positive', () => {
expect(resolveAxisDistribution([1, 100], ScatterAxisScale.Log)).toStrictEqual(
{ distribution: DistributionType.Logarithmic },
);
});
it('falls back from log to a symmetric log around the smallest magnitude when zero is present', () => {
expect(
resolveAxisDistribution([0, 0.05, 300], ScatterAxisScale.Log),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 0.01,
});
});
it('keeps an explicit log axis for a soft min it cannot place', () => {
expect(
resolveAxisDistribution([1, 100], ScatterAxisScale.Log, [-10]),
).toStrictEqual({ distribution: DistributionType.Logarithmic });
});
it('is a symmetric log when asked, even with only positive values', () => {
expect(
resolveAxisDistribution([2, 300], ScatterAxisScale.SymLog),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 1,
});
});
it('uses a unit threshold when nothing is positive', () => {
expect(
resolveAxisDistribution([0, -5], ScatterAxisScale.SymLog),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 1,
});
});
describe('auto', () => {
const SKEWED = [1, 2, 3, 5, 8, 10, 20, 50, 1000];
it.each([
['skewed positive values', SKEWED, DistributionType.Logarithmic],
[
'values spread evenly across decades',
[1, 250, 500, 750, 1000],
DistributionType.Linear,
],
[
'a single outlier among clustered values',
[0.001, 40, 45, 50, 55, 60],
DistributionType.Linear,
],
[
'skewed values spanning under two decades',
[10, 10, 10, 11, 50],
DistributionType.Linear,
],
['no finite values', [Number.NaN], DistributionType.Linear],
])('picks a scale for %s', (_, values, distribution) => {
expect(resolveAxisDistribution(values, ScatterAxisScale.Auto)).toStrictEqual(
{ distribution },
);
});
it('picks symlog for skewed values that include zero', () => {
expect(
resolveAxisDistribution(
[0, 0, 1, 2, 5, 10, 50, 100, 10_000],
ScatterAxisScale.Auto,
),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 1,
});
});
it('picks symlog when a soft limit reaches zero or below', () => {
expect(
resolveAxisDistribution(SKEWED, ScatterAxisScale.Auto, [0]),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 1,
});
});
it('counts a soft limit towards the decades it spans', () => {
expect(
resolveAxisDistribution([5, 50], ScatterAxisScale.Auto, [null, 5000]),
).toStrictEqual({ distribution: DistributionType.Logarithmic });
});
it('is the default', () => {
expect(resolveAxisDistribution(SKEWED)).toStrictEqual({
distribution: DistributionType.Logarithmic,
});
});
});
});

View File

@@ -0,0 +1,88 @@
import { ScatterAxisScale, type ScatterSeries } from '../types';
import { buildScatterConfig } from '../utils';
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
getStoredSeriesVisibility: jest.fn(),
}));
const SERIES: ScatterSeries[] = [
{ label: 'cart', xs: [10, 20], ys: [100, 200], sizes: [1, null] },
{ label: 'checkout', xs: [30], ys: [0] },
];
describe('buildScatterConfig', () => {
const build = (
overrides: Partial<Parameters<typeof buildScatterConfig>[0]> = {},
): ReturnType<typeof buildScatterConfig> =>
buildScatterConfig({
id: 'scatter',
series: SERIES,
isDarkMode: true,
x: { unit: 'reqps' },
y: { unit: 'ms', scale: ScatterAxisScale.Log },
...overrides,
});
it('draws a line along both plot edges', () => {
const [xAxis, yAxis] = build().getConfig().axes ?? [];
expect(xAxis?.border?.show).toBe(true);
expect(yAxis?.border?.show).toBe(true);
});
it('titles each axis with its label, and leaves an unlabelled one bare', () => {
const config = build({
x: { unit: 'reqps', label: 'Throughput' },
y: { unit: 'ms', label: '' },
}).getConfig();
const [xAxis, yAxis] = config.axes ?? [];
expect(xAxis?.label).toBe('Throughput');
expect(yAxis?.label).toBeUndefined();
});
it('emits a faceted plot with two value scales', () => {
const config = build().getConfig();
expect(config.mode).toBe(2);
expect(config.scales?.x).toMatchObject({ time: false, distr: 1 });
// The y column has a 0, so log becomes the symmetric variant.
expect(config.scales?.y).toMatchObject({ time: false, distr: 4 });
});
it('draws one faceted series per group with the plugin path builder', () => {
const config = build().getConfig();
const [, cart, checkout] = config.series ?? [];
expect(config.series).toHaveLength(3);
expect(cart).toMatchObject({
label: 'cart',
facets: [
{ scale: 'x', auto: true },
{ scale: 'y', auto: true },
],
});
expect(typeof cart?.paths).toBe('function');
expect(cart?.paths).toBe(checkout?.paths);
expect(cart?.points?.show).toBe(false);
});
it('formats both axes with their units', () => {
const config = build().getConfig();
const [xAxis, yAxis] = config.axes ?? [];
expect(xAxis).toMatchObject({ scale: 'x', side: 2, space: 90 });
expect(yAxis).toMatchObject({ scale: 'y', side: 3 });
expect(typeof xAxis?.values).toBe('function');
expect(typeof yAxis?.values).toBe('function');
});
it('registers a y threshold draw hook when thresholds are given', () => {
const config = build({
thresholds: [{ thresholdValue: 300, thresholdUnit: 'ms' }],
}).getConfig();
expect(config.hooks?.draw).toHaveLength(1);
expect(build().getConfig().hooks?.draw).toBeUndefined();
});
});

View File

@@ -0,0 +1,98 @@
import { DistributionType } from 'lib/uPlotV2/config/types';
import uPlot from 'uplot';
import { createScatterRange } from '../scatterRange';
import type { ScatterRangeOptions } from '../types';
/** A 220px-tall, 420px-wide plot at a device pixel ratio of 1. */
const plot = { bbox: { width: 420, height: 220 } } as unknown as uPlot;
function rangeOf(
options: Partial<ScatterRangeOptions>,
dataMin: number | null,
dataMax: number | null,
): [number, number] {
const range = createScatterRange({
dimension: 'y',
marginPx: 10,
distribution: DistributionType.Linear,
...options,
}) as (u: uPlot, min: number | null, max: number | null) => [number, number];
return range(plot, dataMin, dataMax);
}
describe('createScatterRange', () => {
beforeAll(() => {
Object.defineProperty(uPlot, 'pxRatio', { value: 1, configurable: true });
});
it('widens a linear range so the data sits marginPx inside each edge', () => {
// 200px of data inside 220px: 10px at each end is 1/20 of the span.
expect(rangeOf({}, 0, 100)).toStrictEqual([-5, 105]);
});
it('measures the margin along its own axis', () => {
// 400px of data inside 420px.
expect(rangeOf({ dimension: 'x' }, 0, 100)).toStrictEqual([-2.5, 102.5]);
});
it('pads a log range in decades, without snapping to the next power of ten', () => {
const [min, max] = rangeOf(
{ distribution: DistributionType.Logarithmic },
1,
10,
);
expect(Math.log10(min)).toBeCloseTo(-0.05);
expect(Math.log10(max)).toBeCloseTo(1.05);
});
it('pads a symmetric log range in its asinh space', () => {
const [min, max] = rangeOf(
{ distribution: DistributionType.SymmetricLog, asinhThreshold: 2 },
0,
20,
);
const span = Math.asinh(10);
expect(Math.asinh(min / 2)).toBeCloseTo(-span / 20);
expect(Math.asinh(max / 2)).toBeCloseTo(span + span / 20);
});
it('stretches to the soft limits before padding', () => {
expect(rangeOf({ softMin: 0, softMax: 200 }, 50, 100)).toStrictEqual([
-10, 210,
]);
});
it('ignores a soft min at or below zero on a log scale', () => {
const [min] = rangeOf(
{ distribution: DistributionType.Logarithmic, softMin: 0 },
10,
100,
);
expect(min).toBeGreaterThan(0);
});
it('spreads a single value around itself', () => {
const [min, max] = rangeOf({}, 5, 5);
expect(min).toBeLessThan(5);
expect(max).toBeGreaterThan(5);
expect(5 - min).toBeCloseTo(max - 5);
});
it('falls back to a unit range with no data', () => {
expect(rangeOf({}, null, null)).toStrictEqual([0, 1]);
expect(
rangeOf({ distribution: DistributionType.Logarithmic }, null, null),
).toStrictEqual([1, 10]);
});
it('skips the margin on a plot too small to hold it', () => {
const tiny = { bbox: { width: 15, height: 15 } } as unknown as uPlot;
const range = createScatterRange({
dimension: 'y',
marginPx: 10,
distribution: DistributionType.Linear,
}) as (u: uPlot, min: number, max: number) => [number, number];
expect(range(tiny, 0, 100)).toStrictEqual([0, 100]);
});
});

View File

@@ -0,0 +1,51 @@
import type uPlot from 'uplot';
import type { ScatterSeries } from '../types';
import { getCursorHit, prepareScatterChartData } from '../utils';
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
getStoredSeriesVisibility: jest.fn(),
}));
const SERIES: ScatterSeries[] = [
{ label: 'cart', xs: [10, 20], ys: [100, 200], sizes: [1, null] },
{ label: 'checkout', xs: [30], ys: [0] },
];
describe('prepareScatterChartData', () => {
it('lays series out as facets behind an empty x slot', () => {
expect(prepareScatterChartData(SERIES)).toStrictEqual([
null,
[
[10, 20],
[100, 200],
[1, null],
],
[[30], [0]],
]);
});
});
describe('getCursorHit', () => {
const plotWith = (idxs: Array<number | null>): uPlot =>
({ cursor: { idxs } }) as unknown as uPlot;
it('reads the series the scatter plugin answered for', () => {
expect(getCursorHit(plotWith([null, null, 4, null]))).toStrictEqual({
seriesIndex: 2,
dataIndex: 4,
});
});
it('takes a data index of 0 as a hit', () => {
expect(getCursorHit(plotWith([0, 0]))).toStrictEqual({
seriesIndex: 1,
dataIndex: 0,
});
});
it('is null off every dot', () => {
expect(getCursorHit(plotWith([null, null]))).toBeNull();
expect(getCursorHit({ cursor: {} } as unknown as uPlot)).toBeNull();
});
});

View File

@@ -0,0 +1,130 @@
import { DistributionType } from 'lib/uPlotV2/config/types';
import { getScaleTransform } from './scatterRange';
import { type AxisDistribution, ScatterAxisScale } from './types';
const LINEAR_DISTRIBUTION: AxisDistribution = {
distribution: DistributionType.Linear,
};
/** Orders of magnitude `auto` needs before it considers leaving linear. */
export const AUTO_MIN_DECADES = 2;
/** How many times more axis the middle half of the points must cover for `auto` to leave linear. */
export const AUTO_SPREAD_GAIN = 2;
function getMagnitudeRange(values: number[]): {
minMagnitude: number;
maxMagnitude: number;
hasNonPositive: boolean;
} {
let minMagnitude = Infinity;
let maxMagnitude = -Infinity;
let hasNonPositive = false;
for (const value of values) {
if (!Number.isFinite(value)) {
continue;
}
if (value <= 0) {
hasNonPositive = true;
}
if (value !== 0) {
minMagnitude = Math.min(minMagnitude, Math.abs(value));
maxMagnitude = Math.max(maxMagnitude, Math.abs(value));
}
}
return { minMagnitude, maxMagnitude, hasNonPositive };
}
/** The linear band ends at the smallest non-zero magnitude so small values still spread. */
function symmetricLogDistribution(minMagnitude: number): AxisDistribution {
const asinhThreshold = Number.isFinite(minMagnitude)
? 10 ** Math.floor(Math.log10(minMagnitude))
: 1;
return { distribution: DistributionType.SymmetricLog, asinhThreshold };
}
function quantile(sorted: number[], q: number): number {
const position = (sorted.length - 1) * q;
const lower = Math.floor(position);
const upper = Math.ceil(position);
return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
}
/** Share of the axis the middle half of the points covers. */
function middleHalfSpread(
sorted: number[],
[min, max]: [number, number],
distribution: AxisDistribution,
): number {
const { forward } = getScaleTransform(distribution);
const span = forward(max) - forward(min);
return span > 0
? (forward(quantile(sorted, 0.75)) - forward(quantile(sorted, 0.25))) / span
: 0;
}
/**
* Log (symlog with zero or negatives) when it spreads the points out clearly
* more than linear does; min/max alone would let one outlier decide.
*/
function resolveAutoDistribution(
values: number[],
softLimits: number[],
): AxisDistribution {
const sorted = values
.filter((value) => Number.isFinite(value))
.sort((a, b) => a - b);
if (sorted.length < 2) {
return LINEAR_DISTRIBUTION;
}
const { minMagnitude, maxMagnitude, hasNonPositive } = getMagnitudeRange([
...sorted,
...softLimits,
]);
if (!(Math.log10(maxMagnitude / minMagnitude) >= AUTO_MIN_DECADES)) {
return LINEAR_DISTRIBUTION;
}
const candidate = hasNonPositive
? symmetricLogDistribution(minMagnitude)
: { distribution: DistributionType.Logarithmic };
const extent: [number, number] = [
Math.min(sorted[0], ...softLimits),
Math.max(sorted[sorted.length - 1], ...softLimits),
];
const candidateSpread = middleHalfSpread(sorted, extent, candidate);
const linearSpread = middleHalfSpread(sorted, extent, LINEAR_DISTRIBUTION);
return candidateSpread > 0 &&
candidateSpread >= AUTO_SPREAD_GAIN * linearSpread
? candidate
: LINEAR_DISTRIBUTION;
}
/** `log` falls back to symlog rather than drop zero or negatives; callers filter first to drop them. */
export function resolveAxisDistribution(
values: number[],
scale: ScatterAxisScale = ScatterAxisScale.Auto,
/** Soft limits and thresholds the axis must reach; only `auto` weighs them. */
softLimits: Array<number | null | undefined> = [],
): AxisDistribution {
switch (scale) {
case ScatterAxisScale.Linear:
return LINEAR_DISTRIBUTION;
case ScatterAxisScale.SymLog:
return symmetricLogDistribution(getMagnitudeRange(values).minMagnitude);
case ScatterAxisScale.Log: {
const { minMagnitude, hasNonPositive } = getMagnitudeRange(values);
return hasNonPositive
? symmetricLogDistribution(minMagnitude)
: { distribution: DistributionType.Logarithmic };
}
case ScatterAxisScale.Auto:
default:
return resolveAutoDistribution(
values,
softLimits.filter(
(limit): limit is number => limit != null && Number.isFinite(limit),
),
);
}
}

View File

@@ -0,0 +1,92 @@
import { DistributionType } from 'lib/uPlotV2/config/types';
import { DEFAULT_ASINH_THRESHOLD } from 'lib/uPlotV2/utils/scale';
import uPlot from 'uplot';
import type {
AxisDistribution,
ScaleTransform,
ScatterRangeOptions,
} from './types';
export function getScaleTransform({
distribution,
asinhThreshold = DEFAULT_ASINH_THRESHOLD,
}: AxisDistribution): ScaleTransform {
switch (distribution) {
case DistributionType.Logarithmic:
return {
forward: (value): number => Math.log10(value),
inverse: (position): number => 10 ** position,
};
case DistributionType.SymmetricLog:
return {
forward: (value): number => Math.asinh(value / asinhThreshold),
inverse: (position): number => Math.sinh(position) * asinhThreshold,
};
default:
return {
forward: (value): number => value,
inverse: (position): number => position,
};
}
}
/** In transformed units. */
const SINGLE_VALUE_HALF_SPAN = 0.5;
/** The data's extent stretched to the soft limits; null with neither. */
function resolveExtent(
dataMin: number | null,
dataMax: number | null,
softMin: number | null | undefined,
softMax: number | null | undefined,
): [number, number] | null {
const lows = [dataMin, softMin].filter(
(value): value is number => value != null && Number.isFinite(value),
);
const highs = [dataMax, softMax].filter(
(value): value is number => value != null && Number.isFinite(value),
);
return lows.length > 0 && highs.length > 0
? [Math.min(...lows), Math.max(...highs)]
: null;
}
/** Data extent padded by `marginPx` so edge dots draw whole; log stays unsnapped (snapping after the margin can add an empty decade). */
export function createScatterRange({
dimension,
marginPx,
softMin,
softMax,
...distribution
}: ScatterRangeOptions): uPlot.Scale.Range {
const transform = getScaleTransform(distribution);
const isLog = distribution.distribution === DistributionType.Logarithmic;
// A log scale cannot place a soft limit at or below zero.
const usableSoftMin = isLog && (softMin ?? 0) <= 0 ? null : softMin;
return (u, dataMin, dataMax): uPlot.Range.MinMax => {
const extent = resolveExtent(dataMin, dataMax, usableSoftMin, softMax);
if (!extent) {
return isLog ? [1, 10] : [0, 1];
}
let start = transform.forward(extent[0]);
let end = transform.forward(extent[1]);
if (start === end) {
start -= SINGLE_VALUE_HALF_SPAN;
end += SINGLE_VALUE_HALF_SPAN;
}
const plotPx =
(dimension === 'x' ? u.bbox.width : u.bbox.height) / uPlot.pxRatio;
if (plotPx > 2 * marginPx) {
const pad = ((end - start) * marginPx) / (plotPx - 2 * marginPx);
start -= pad;
end += pad;
}
return [transform.inverse(start), transform.inverse(end)];
};
}

View File

@@ -0,0 +1,67 @@
import type { PrecisionOption } from 'components/Graph/types';
import type {
DistributionType,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import type { Threshold } from 'lib/uPlotV2/hooks/types';
import type { ScatterPointSize } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
export interface ScatterSeries {
label: string;
xs: number[];
ys: number[];
/** Optional third channel, in the caller's units. */
sizes?: Array<number | null>;
}
export enum ScatterAxisScale {
/** Log or symlog when it spreads the points out more than linear does. */
Auto = 'auto',
Linear = 'linear',
Log = 'log',
/** Log-like, but places zero and negatives. */
SymLog = 'symlog',
}
export interface ScatterAxisOptions {
label?: string;
unit?: string;
softMin?: number | null;
softMax?: number | null;
scale?: ScatterAxisScale;
}
export interface BuildScatterConfigArgs {
id: string;
series: ScatterSeries[];
isDarkMode: boolean;
x: ScatterAxisOptions;
y: ScatterAxisOptions;
pointSize?: ScatterPointSize;
/** 0–1. */
fillOpacity?: number;
colorMapping?: Record<string, string>;
/** Drawn on the y axis. */
thresholds?: Threshold[];
decimalPrecision?: PrecisionOption;
selectionPreferencesSource?: SelectionPreferencesSource;
shouldSaveSelectionPreference?: boolean;
}
export interface AxisDistribution {
distribution: DistributionType;
asinhThreshold?: number;
}
export interface ScaleTransform {
forward: (value: number) => number;
inverse: (position: number) => number;
}
export interface ScatterRangeOptions extends AxisDistribution {
dimension: 'x' | 'y';
/** CSS pixels kept clear at each end so the largest dot draws whole. */
marginPx: number;
softMin?: number | null;
softMax?: number | null;
}

View File

@@ -0,0 +1,194 @@
import {
type AxisProps,
DistributionType,
DrawStyle,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import {
applyScatterPlugin,
createScatterPlugin,
SCATTER_FACETS,
} from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
import {
DEFAULT_SCATTER_POINT_SIZE,
ScatterChartData,
ScatterSeriesData,
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import {
logScaleSplits,
spacedLogLabels,
} from 'lib/uPlotV2/utils/logGridSplits';
import { adjustSoftLimitsWithThresholds } from 'lib/uPlotV2/utils/scale';
import uPlot from 'uplot';
import { resolveAxisDistribution } from './axisDistribution';
import { createScatterRange } from './scatterRange';
import type {
AxisDistribution,
BuildScatterConfigArgs,
ScatterSeries,
} from './types';
const POINT_STROKE_WIDTH = 1;
/** Unit-suffixed x labels are wider than uPlot's 50px default assumes. */
const X_AXIS_TICK_SPACE_PX = 90;
const X_AXIS_END_LABEL_PADDING_PX = 40;
/** The plugin's `dataIdx` answers only for the hit series. */
export function getCursorHit(
plot: uPlot,
): { seriesIndex: number; dataIndex: number } | null {
const idxs = plot.cursor.idxs ?? [];
for (let seriesIndex = 1; seriesIndex < idxs.length; seriesIndex++) {
const dataIndex = idxs[seriesIndex];
if (dataIndex != null) {
return { seriesIndex, dataIndex };
}
}
return null;
}
/** `[null, [xs, ys, sizes?], …]`: uPlot's faceted layout, series 0 empty. */
export function prepareScatterChartData(
series: ScatterSeries[],
): uPlot.AlignedData {
const data: ScatterChartData = [
null,
...series.map((entry): ScatterSeriesData =>
entry.sizes ? [entry.xs, entry.ys, entry.sizes] : [entry.xs, entry.ys],
),
];
return data as unknown as uPlot.AlignedData;
}
/** The scatter range is unsnapped, so a log axis places and labels its own ticks. */
function getLogAxisTicks({
distribution,
}: AxisDistribution): Pick<AxisProps, 'splits' | 'filter'> {
return distribution === DistributionType.Logarithmic
? { splits: logScaleSplits, filter: spacedLogLabels }
: {};
}
export function buildScatterConfig({
id,
series,
isDarkMode,
x,
y,
pointSize = DEFAULT_SCATTER_POINT_SIZE,
fillOpacity,
colorMapping = {},
thresholds,
decimalPrecision,
selectionPreferencesSource,
shouldSaveSelectionPreference,
}: BuildScatterConfigArgs): UPlotConfigBuilder {
const builder = new UPlotConfigBuilder({
id,
selectionPreferencesSource,
shouldSaveSelectionPreference,
});
const plugin = createScatterPlugin({ pointSize });
applyScatterPlugin(builder, plugin);
// The last x label is centred on the plot's right edge; room for its unit.
builder.setPadding([16, X_AXIS_END_LABEL_PADDING_PX, 8, 8]);
const ySoftLimits = adjustSoftLimitsWithThresholds(
y.softMin ?? null,
y.softMax ?? null,
thresholds,
y.unit,
);
const xDistribution = resolveAxisDistribution(
series.flatMap((entry) => entry.xs),
x.scale,
[x.softMin, x.softMax],
);
const yDistribution = resolveAxisDistribution(
series.flatMap((entry) => entry.ys),
y.scale,
[ySoftLimits.softMin, ySoftLimits.softMax],
);
const yThresholds =
thresholds && thresholds.length > 0
? { scaleKey: 'y', thresholds, yAxisUnit: y.unit }
: undefined;
// The largest disc drawn, plus its outline, kept clear of each plot edge.
const largestDiameter = series.some((entry) => entry.sizes)
? pointSize.max
: pointSize.fixed;
const marginPx = largestDiameter / 2 + POINT_STROKE_WIDTH;
builder.addScale({
scaleKey: 'x',
time: false,
range: createScatterRange({
dimension: 'x',
marginPx,
softMin: x.softMin,
softMax: x.softMax,
...xDistribution,
}),
...xDistribution,
});
builder.addScale({
scaleKey: 'y',
time: false,
range: createScatterRange({
dimension: 'y',
marginPx,
...ySoftLimits,
...yDistribution,
}),
...yDistribution,
});
builder.addAxis({
scaleKey: 'x',
side: 2,
label: x.label || undefined,
isDarkMode,
isTimeAxis: false,
yAxisUnit: x.unit ?? '',
decimalPrecision,
isLogScale: xDistribution.distribution !== DistributionType.Linear,
...getLogAxisTicks(xDistribution),
space: X_AXIS_TICK_SPACE_PX,
});
builder.addAxis({
scaleKey: 'y',
side: 3,
label: y.label || undefined,
isDarkMode,
yAxisUnit: y.unit ?? '',
decimalPrecision,
isLogScale: yDistribution.distribution !== DistributionType.Linear,
...getLogAxisTicks(yDistribution),
});
series.forEach((entry) => {
builder.addSeries({
scaleKey: 'y',
label: entry.label,
colorMapping,
drawStyle: DrawStyle.Scatter,
pathBuilder: plugin.pathBuilder,
facets: SCATTER_FACETS,
lineWidth: POINT_STROKE_WIDTH,
pointSize: pointSize.fixed,
fillOpacity,
isDarkMode,
});
});
if (yThresholds) {
builder.addThresholds(yThresholds);
}
return builder;
}

View File

@@ -16,6 +16,10 @@ import type {
HeatmapColorOptions,
HeatmapSeries,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import type {
ScatterChannels,
ScatterPointLabel,
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
@@ -88,6 +92,26 @@ export interface HistogramChartProps extends ChartWrapperProps {
isQueriesMerged?: boolean;
}
/** The dot a click landed on; `seriesIndex` is uPlot's, so the first series is 1. */
export interface ScatterPointClick {
seriesIndex: number;
dataIndex: number;
color: string;
/** Viewport coordinates of the click. */
coordinates: { x: number; y: number };
}
/** `data` is mode-2 (`prepareScatterChartData`); `config` comes from `buildScatterConfig`. */
export interface ScatterChartProps extends ChartWrapperProps {
channels: ScatterChannels;
resolvePointGroupValues?: (
seriesIndex: number,
dataIndex: number,
) => ScatterPointLabel[];
/** Fires for a click on a dot; a click on empty plot space fires nothing. */
onPointClick?: (click: ScatterPointClick) => void;
}
/**
* Data arrives as the query response carries it — bucket bounds plus one series per
* group — and the chart pivots and sums it, so no caller has to get the transpose

View File

@@ -25,9 +25,6 @@
.ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box
) {
@include custom-scrollbar;
max-height: 150px;
overflow-y: auto;
padding: var(--spacing-4) !important;
}
}

View File

@@ -5,7 +5,6 @@ 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';
@@ -20,7 +19,6 @@ interface MessagesAccordionProps extends Pick<
icon: ReactNode;
title: string;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
@@ -28,7 +26,6 @@ function MessagesAccordion({
icon,
title,
messages,
raw,
testId,
messageType,
canLinkToToolCall,
@@ -36,10 +33,8 @@ function MessagesAccordion({
}: 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) {
if (messages.length === 0) {
return null;
}
@@ -66,12 +61,12 @@ function MessagesAccordion({
{title}
</span>
),
extra: isOpen && canFormat && (
extra: isOpen && (
// 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}
value={view}
onChange={setView}
testId={`${testId}-view-toggle`}
/>
@@ -79,9 +74,8 @@ function MessagesAccordion({
),
children: (
<MessagesSection
view={effectiveView}
view={view}
messages={messages}
raw={raw}
testId={`${testId}-body`}
messageType={messageType}
canLinkToToolCall={canLinkToToolCall}
@@ -95,8 +89,4 @@ function MessagesAccordion({
);
}
MessagesAccordion.defaultProps = {
raw: undefined,
};
export default MessagesAccordion;

View File

@@ -3,7 +3,10 @@ import { ArrowDownRight, ArrowUpRight } from '@signozhq/icons';
import { MessageType } from '../../AIThreadMessage/types';
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
import { getSpanMessages, hasAnyMessages } from '../../utils/spanMessages';
import {
getSpanMessages,
hasFormattedMessages,
} from '../../utils/spanMessages';
import { ThreadSpan } from '../../TraceDetailsThread/types';
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
import MessagesAccordion from './MessagesAccordion';
@@ -25,7 +28,7 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
toolCallKeys,
);
if (!hasAnyMessages(data)) {
if (!hasFormattedMessages(data)) {
return null;
}
@@ -35,7 +38,6 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
icon={<ArrowUpRight size={14} />}
title="Input"
messages={data.formattedInput}
raw={data.rawInput}
messageType={MessageType.Input}
testId="span-ai-messages-input"
canLinkToToolCall={canLinkToToolCall}
@@ -45,7 +47,6 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
icon={<ArrowDownRight size={14} />}
title="Output"
messages={data.formattedOutput}
raw={data.rawOutput}
messageType={MessageType.Output}
testId="span-ai-messages-output"
canLinkToToolCall={canLinkToToolCall}

View File

@@ -47,6 +47,7 @@
border-radius: 50%;
background: var(--accent-forest);
flex-shrink: 0;
margin-right: 4px;
}
// Truncating text inside a badge (service name, status message).

View File

@@ -3,8 +3,7 @@ import AIThreadMessage, {
} from '../AIThreadMessage/AIThreadMessage';
import { ThreadMessage } from '../AIThreadMessage/types';
import { ThreadView } from '../TraceDetailsThread/types';
import RawMessages from './RawMessages';
import { RawMessagesValue } from '../utils/spanMessages';
import JsonBlock from '../AIThreadMessage/JsonBlock';
import styles from './SpanMessages.module.scss';
@@ -17,7 +16,6 @@ interface MessagesSectionProps extends MessageProps {
title?: string;
view: ThreadView;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
@@ -25,21 +23,19 @@ 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) {
if (messages.length === 0) {
return null;
}
return (
<section className={styles.section} data-testid={testId}>
{title && <div className={styles.sectionTitle}>{title}</div>}
{isFormatted ? (
{view === ThreadView.Formatted ? (
<div className={styles.messages}>
{messages.map((message, index) => (
<AIThreadMessage
@@ -54,7 +50,7 @@ function MessagesSection({
))}
</div>
) : (
<RawMessages value={raw as RawMessagesValue} />
<JsonBlock data={messages} />
)}
</section>
);
@@ -62,7 +58,6 @@ function MessagesSection({
MessagesSection.defaultProps = {
title: undefined,
raw: undefined,
};
export default MessagesSection;

View File

@@ -1,21 +0,0 @@
import JsonBlock from '../AIThreadMessage/JsonBlock';
import { RawMessagesValue } from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
interface RawMessagesProps {
value: RawMessagesValue;
}
function RawMessages({ value }: RawMessagesProps): JSX.Element {
if (typeof value === 'object') {
return <JsonBlock data={value} />;
}
return (
<span className={styles.rawUnavailable} data-testid="span-raw-messages">
Unable to render messages
</span>
);
}
export default RawMessages;

View File

@@ -28,12 +28,6 @@
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);

View File

@@ -6,11 +6,7 @@ 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 { getSpanMessages, hasFormattedMessages } from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
@@ -32,9 +28,8 @@ function SpanMessages({
onToolLinkClick,
}: SpanMessagesProps): JSX.Element {
const data = useMemo(() => getSpanMessages(span), [span]);
const isFormatted = view === ThreadView.Formatted;
if (isFormatted ? !hasFormattedMessages(data) : !hasRawMessages(data)) {
if (!hasFormattedMessages(data)) {
return (
<div
className={cx(styles.missing, className)}
@@ -45,11 +40,11 @@ function SpanMessages({
);
}
if (!isFormatted) {
if (view === ThreadView.Json) {
return (
<div className={cx(styles.root, className)} data-testid="span-messages-json">
<JsonBlock
data={{ input: data.rawInput ?? [], output: data.rawOutput ?? [] }}
data={{ input: data.formattedInput, output: data.formattedOutput }}
/>
</div>
);
@@ -60,7 +55,6 @@ function SpanMessages({
<MessagesSection
view={view}
messages={data.formattedInput}
raw={data.rawInput}
messageType={MessageType.Input}
testId="span-messages-input"
canLinkToToolCall={canLinkToToolCall}
@@ -69,7 +63,6 @@ function SpanMessages({
<MessagesSection
view={view}
messages={data.formattedOutput}
raw={data.rawOutput}
messageType={MessageType.Output}
testId="span-messages-output"
canLinkToToolCall={canLinkToToolCall}

View File

@@ -48,41 +48,36 @@ describe('SpanMessages', () => {
).toBeInTheDocument();
});
it('shows the empty state in JSON view when there are no raw messages', () => {
render(<SpanMessages span={makeSpan({})} view={ThreadView.Json} />);
it('shows the empty state in JSON view when there are no formatted messages', () => {
const span = makeSpan({
attributes: { [GEN_AI_KEYS.inputMessages]: '[{"role":"user"}]' },
});
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByTestId('span-messages-empty')).toBeInTheDocument();
});
it('renders raw messages in JSON view', () => {
it('renders formatted messages in JSON view, ignoring the raw attribute', () => {
const span = makeSpan({
attributes: { [GEN_AI_KEYS.outputMessages]: '[{"role":"assistant"}]' },
formattedInput: [
{ role: 'user', parts: [{ type: 'text', content: 'formatted-text' }] },
],
attributes: {
[GEN_AI_KEYS.inputMessages]: '[{"role":"user","content":"raw-text"}]',
},
});
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();
expect(screen.getByText(/formatted-text/)).toBeInTheDocument();
expect(screen.queryByText(/raw-text/)).not.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' },
]),
},
formattedInput: [{ role: 'user', parts: [{ type: 'text', content: 'hi' }] }],
});
render(<SpanMessages span={span} view={ThreadView.Json} />);

View File

@@ -99,10 +99,12 @@ function TraceDetailsHeader({
const handlePreviousBtnClick = useCallback((): void => {
if (hasInAppHistory()) {
history.goBack();
} else if (!isOverview) {
history.push(ROUTES.AI_OBSERVABILITY_EXPLORER);
} else {
history.push(ROUTES.TRACES_EXPLORER);
}
}, []);
}, [isOverview]);
const handleToggleTraceDetails = useCallback((): void => {
setShowTraceDetails((prev) => !prev);

View File

@@ -22,7 +22,8 @@ function TraceDetailsTabs(): JSX.Element {
const [tab, setTab] = useTraceDetailsTab();
const handleChange = (value: string): void => {
void setTab(value as TraceDetailsTab);
// Pushed so the thread's "Go back" returns to the previous tab, not the previous page.
void setTab(value as TraceDetailsTab, { history: 'push' });
};
return (

View File

@@ -1,10 +1,8 @@
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';
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
import TraceDetailsHeader from '../TraceDetailsHeader';
import { useTraceSummary } from '../useTraceSummary';
@@ -100,6 +98,22 @@ describe('TraceDetailsHeader – back button', () => {
expect(mockPush).toHaveBeenCalledWith(ROUTES.TRACES_EXPLORER);
expect(mockGoBack).not.toHaveBeenCalled();
});
it('pushes to the AI observability explorer when opened directly on another tab', async () => {
mockHasInAppHistory.mockReturnValue(false);
const user = userEvent.setup({ delay: null });
render(
<NuqsTestingAdapter searchParams="?tab=thread">
<TraceDetailsHeader {...baseProps} />
</NuqsTestingAdapter>,
);
await user.click(screen.getByRole('button', { name: /back/i }));
expect(mockPush).toHaveBeenCalledTimes(1);
expect(mockPush).toHaveBeenCalledWith(ROUTES.AI_OBSERVABILITY_EXPLORER);
expect(mockGoBack).not.toHaveBeenCalled();
});
});
describe('TraceDetailsHeader – action cluster', () => {

View File

@@ -1,29 +1,31 @@
.root {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-12);
padding: var(--spacing-20) var(--spacing-24);
}
.message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-4);
max-width: 428px;
padding: var(--spacing-16);
text-align: center;
}
.emoji {
font-size: 32px;
line-height: 1;
.noDataImg {
height: 32px;
width: 32px;
}
.paragraphs {
display: flex;
flex-direction: column;
gap: var(--paragraph-base-500-line-height);
}
.highlight {
.title {
font-size: var(--periscope-font-size-medium);
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
.description {
max-width: 420px;
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.actions {
margin-top: var(--spacing-4);
}

View File

@@ -1,8 +1,9 @@
import { ArrowRight } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { openInNewTab } from 'utils/navigation';
import noDataUrl from '@/assets/Icons/no-data.svg';
import { LLM_OBSERVABILITY_DOCS_URL } from '../constants';
import styles from './ThreadNoAiSpans.module.scss';
@@ -10,32 +11,28 @@ import styles from './ThreadNoAiSpans.module.scss';
function ThreadNoAiSpans(): JSX.Element {
return (
<div className={styles.root} data-testid="thread-empty">
<div className={styles.message}>
<span className={styles.emoji} aria-hidden>
🚏
</span>
<div className={styles.paragraphs}>
<Typography size="base" weight="medium" color="muted">
Looks like this trace does not have any spans from AI-related actions.
</Typography>
<Typography size="base" weight="medium" className={styles.highlight}>
Thread view summarising your entire AI-journey history will appear here
when the spans have the necessary datapoints.
</Typography>
</div>
<img src={noDataUrl} alt="" className={styles.noDataImg} />
<span className={styles.title}>
This trace has no spans from AI-related actions
</span>
<span className={styles.description}>
Thread view summarising your entire AI-journey history will appear here when
the spans have the necessary datapoints.
</span>
<div className={styles.actions}>
<Button
variant="solid"
color="secondary"
size="md"
suffix={<ArrowRight size={12} />}
onClick={(): void => {
openInNewTab(LLM_OBSERVABILITY_DOCS_URL);
}}
testId="thread-empty-learn-more"
>
Learn more
</Button>
</div>
<Button
variant="solid"
color="secondary"
size="md"
suffix={<ArrowRight size={12} />}
onClick={(): void => {
openInNewTab(LLM_OBSERVABILITY_DOCS_URL);
}}
testId="thread-empty-learn-more"
>
Learn more
</Button>
</div>
);
}

View File

@@ -40,6 +40,7 @@ function TraceDetailsThread(): JSX.Element {
spansAboveFirstPage,
isLoading,
isError,
isNotFound,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
@@ -52,6 +53,7 @@ function TraceDetailsThread(): JSX.Element {
interestedSpanId,
spans,
isLoading,
isNotFound,
);
const interestedSpanIndex = spans.findIndex(
(span) => span.span_id === interestedSpanId,

View File

@@ -1,7 +1,7 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { getTraceThread } from 'api/generated/services/tracedetail';
import { act, renderHook, waitFor } from 'tests/test-utils';
import { useTraceThread } from '../useTraceThread';
@@ -154,6 +154,7 @@ describe('useTraceThread', () => {
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockGetTraceThread).toHaveBeenCalledTimes(1);
expect(result.current.isNotFound).toBe(true);
});
it('retries other errors', async () => {
@@ -162,5 +163,6 @@ describe('useTraceThread', () => {
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockGetTraceThread).toHaveBeenCalledTimes(4);
expect(result.current.isNotFound).toBe(false);
});
});

View File

@@ -25,6 +25,8 @@ interface UseTraceThreadResult {
spansAboveFirstPage: number;
isLoading: boolean;
isError: boolean;
/** The interested span (or trace) doesn't exist; an empty thread is not this. */
isNotFound: boolean;
hasNextPage: boolean;
hasPreviousPage: boolean;
isFetchingNextPage: boolean;
@@ -99,6 +101,7 @@ export function useTraceThread({
spansAboveFirstPage,
isLoading: query.isLoading,
isError: query.isError,
isNotFound: isNotFoundError(query.error),
hasNextPage: !!query.hasNextPage,
hasPreviousPage: !!query.hasPreviousPage,
isFetchingNextPage: query.isFetchingNextPage,

View File

@@ -28,6 +28,7 @@ export function getInterestedSpanStatus(
interestedSpanId: string | undefined,
spans: ThreadSpan[],
isLoading: boolean,
isNotFound: boolean,
): InterestedSpanStatus {
if (!interestedSpanId) {
return InterestedSpanStatus.None;
@@ -35,9 +36,13 @@ export function getInterestedSpanStatus(
if (isLoading) {
return InterestedSpanStatus.Loading;
}
if (spans.length === 0) {
if (isNotFound) {
return InterestedSpanStatus.NotFound;
}
// The span exists but the thread is empty, so the no-AI-spans state applies.
if (spans.length === 0) {
return InterestedSpanStatus.None;
}
return spans.some((span) => span.span_id === interestedSpanId)
? InterestedSpanStatus.Found
: InterestedSpanStatus.NoIOMessages;

View File

@@ -60,4 +60,4 @@ export enum TraceDetailsTab {
}
export const LLM_OBSERVABILITY_DOCS_URL =
'https://signoz.io/docs/llm-observability/';
'https://signoz.io/docs/ai-observability/';

View File

@@ -1,49 +1,19 @@
import { parseJsonObject } from '../AIThreadMessage/utils';
import { ThreadMessage } from '../AIThreadMessage/types';
import { ThreadSpan } from '../TraceDetailsThread/types';
import { GEN_AI_KEYS } from './aiTraceUtils';
/** Parsed JSON, or the original string when it isn't JSON. */
export type RawMessagesValue = object | string;
export interface SpanMessagesData {
formattedInput: ThreadMessage[];
formattedOutput: ThreadMessage[];
rawInput?: RawMessagesValue;
rawOutput?: RawMessagesValue;
}
function toRawValue(value: unknown): RawMessagesValue | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (typeof value === 'string') {
return parseJsonObject(value) ?? value;
}
return typeof value === 'object' ? value : JSON.stringify(value);
}
export function getSpanMessages(span: ThreadSpan): SpanMessagesData {
const rawInput = toRawValue(span.attributes?.[GEN_AI_KEYS.inputMessages]);
const rawOutput = toRawValue(span.attributes?.[GEN_AI_KEYS.outputMessages]);
// Spans from before the JSON column have no formatted messages.
return {
formattedInput: span.formattedInput ?? [],
formattedOutput: span.formattedOutput ?? [],
rawInput,
rawOutput,
};
}
export function hasFormattedMessages(data: SpanMessagesData): boolean {
return data.formattedInput.length > 0 || data.formattedOutput.length > 0;
}
export function hasRawMessages(data: SpanMessagesData): boolean {
return !!data.rawInput || !!data.rawOutput;
}
export function hasAnyMessages(data: SpanMessagesData): boolean {
return hasFormattedMessages(data) || hasRawMessages(data);
}

View File

@@ -140,6 +140,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
Description: "This endpoint creates a notification channel",
Request: new(alertmanagertypes.PostableNotificationChannel),
RequestContentType: "application/json",
RequestExamples: createNotificationChannelExamples(),
Response: new(alertmanagertypes.GettableNotificationChannel),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
@@ -224,6 +225,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
Description: "This endpoint replaces a notification channel's configuration in full. Neither name is part of the request body: both are immutable. The kind may change, which replaces the channel's notifier configuration.",
Request: new(alertmanagertypes.UpdatableNotificationChannel),
RequestContentType: "application/json",
RequestExamples: updateNotificationChannelExamples(),
Response: new(alertmanagertypes.GettableNotificationChannel),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
@@ -309,6 +311,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
Description: "This endpoint sends a test notification for the configuration in the request body. The channel need not exist and nothing is persisted, so the body carries a configuration only.",
Request: new(alertmanagertypes.TestableNotificationChannel),
RequestContentType: "application/json",
RequestExamples: testNotificationChannelExamples(),
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,

View File

@@ -0,0 +1,201 @@
package signozapiserver
import "github.com/SigNoz/signoz/pkg/http/handler"
// channelConfig wraps one provider spec in the {kind, spec} envelope every
// channel write carries.
func channelConfig(kind string, spec map[string]any) map[string]any {
return map[string]any{"kind": kind, "spec": spec}
}
func slackChannelSpec() map[string]any {
return map[string]any{
"apiUrl": "https://hooks.slack.com/services/T000/B000/XXXXXXXX",
"channel": "#alerts-platform",
"sendResolved": true,
}
}
func pagerdutyChannelSpec() map[string]any {
return map[string]any{
"routingKey": "R0000000000000000000000000000000",
"severity": "critical",
}
}
func webhookChannelSpec() map[string]any {
return map[string]any{
"url": "https://alerts.example.com/signoz",
"bearerToken": "00000000-0000-0000-0000-000000000000",
}
}
// createNotificationChannelExamples returns one create payload per channel
// kind. Each carries the kind's required fields plus one or two optional ones
// that show a default being overridden.
func createNotificationChannelExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "slack",
Summary: "Slack incoming webhook",
Description: "Posts to a Slack channel through an incoming webhook. `sendResolved` is off by default for Slack, so it is turned on here.",
Value: map[string]any{
"name": "slack-platform",
"displayName": "Slack platform",
"config": channelConfig("slack", slackChannelSpec()),
},
},
{
Name: "email",
Summary: "Email recipients",
Description: "Sends to a comma-separated recipient list. SMTP transport comes from the deployment's global config, so only recipients and body are configurable.",
Value: map[string]any{
"name": "email-oncall",
"displayName": "Email on-call",
"config": channelConfig("email", map[string]any{
"to": "oncall@example.com, platform@example.com",
"headers": map[string]any{"Subject": "[SigNoz] {{ .CommonLabels.alertname }}"},
}),
},
},
{
Name: "webhook_bearer",
Summary: "Webhook with a bearer token",
Description: "Delivers the Alertmanager webhook payload to an HTTP endpoint. Use either `bearerToken` or `username`/`password`, never both.",
Value: map[string]any{
"name": "webhook-incident-bot",
"displayName": "Incident bot webhook",
"config": channelConfig("webhook", webhookChannelSpec()),
},
},
{
Name: "pagerduty",
Summary: "PagerDuty Events API v2",
Description: "Creates PagerDuty incidents through an Events API v2 integration key.",
Value: map[string]any{
"name": "pagerduty-oncall",
"displayName": "PagerDuty on-call",
"config": channelConfig("pagerduty", pagerdutyChannelSpec()),
},
},
{
Name: "opsgenie",
Summary: "Opsgenie API integration",
Description: "Creates Opsgenie alerts through an API integration key, pinned to priority P2.",
Value: map[string]any{
"name": "opsgenie-sre",
"displayName": "Opsgenie SRE",
"config": channelConfig("opsgenie", map[string]any{
"apiKey": "00000000-0000-0000-0000-000000000000",
"priority": "P2",
}),
},
},
{
Name: "msteams",
Summary: "Microsoft Teams incoming webhook",
Description: "Posts an adaptive card to a Teams channel through an incoming webhook.",
Value: map[string]any{
"name": "msteams-platform",
"displayName": "Teams platform",
"config": channelConfig("msteams", map[string]any{
"webhookUrl": "https://example.webhook.office.com/webhookb2/00000000-0000-0000-0000-000000000000",
}),
},
},
{
Name: "googlechat",
Summary: "Google Chat space webhook",
Description: "Posts to a Google Chat space through its webhook URL.",
Value: map[string]any{
"name": "googlechat-platform",
"displayName": "Google Chat platform",
"config": channelConfig("googlechat", map[string]any{
"webhookUrl": "https://chat.googleapis.com/v1/spaces/AAAA0000000/messages?key=XXXX&token=XXXX",
}),
},
},
{
Name: "jira",
Summary: "Jira Cloud issues",
Description: "Creates one Jira Cloud issue per alert group and reopens it if the alert fires again within `reopenDuration`. Authenticates with an Atlassian account email and API token.",
Value: map[string]any{
"name": "jira-platform",
"displayName": "Jira platform",
"config": channelConfig("jira", map[string]any{
"site": "https://example.atlassian.net",
"project": "OPS",
"issueType": "Bug",
"email": "oncall@example.com",
"apiToken": "XXXXXXXXXXXXXXXXXXXXXXXX",
"labels": []any{"signoz", "alert"},
"reopenDuration": "3d",
}),
},
},
{
Name: "jsmops",
Summary: "Jira Service Management Ops",
Description: "Creates JSM Ops alerts through an integration API key. JSM Ops speaks the Opsgenie alert API, so `priority` takes P1 to P5.",
Value: map[string]any{
"name": "jsmops-oncall",
"displayName": "JSM Ops on-call",
"config": channelConfig("jsmops", map[string]any{
"apiKey": "00000000-0000-0000-0000-000000000000",
"priority": "P1",
"tags": "signoz,platform",
}),
},
},
{
Name: "incidentio",
Summary: "incident.io HTTP alert source",
Description: "Sends alert events to an incident.io HTTP alert source. `url` and `token` are copied from the source's setup page; the token goes without the Bearer prefix.",
Value: map[string]any{
"name": "incidentio-platform",
"displayName": "incident.io platform",
"config": channelConfig("incidentio", map[string]any{
"url": "https://api.incident.io/v2/alert_events/http/00000000-0000-0000-0000-000000000000",
"token": "XXXXXXXXXXXXXXXXXXXXXXXX",
"metadata": map[string]any{"team": "platform"},
}),
},
},
}
}
// updateNotificationChannelExamples shows that an update carries only config:
// name and displayName are immutable, and the kind may change.
func updateNotificationChannelExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "slack_rotate_webhook",
Summary: "Replace a Slack webhook URL",
Description: "Replaces the stored configuration in full; every field not sent falls back to its default.",
Value: map[string]any{"config": channelConfig("slack", slackChannelSpec())},
},
{
Name: "switch_to_pagerduty",
Summary: "Change the kind to PagerDuty",
Description: "The kind may change on update, replacing the channel's notifier configuration while its name and references stay.",
Value: map[string]any{"config": channelConfig("pagerduty", pagerdutyChannelSpec())},
},
}
}
// testNotificationChannelExamples sends one test notification; the body is a
// configuration only because nothing is persisted.
func testNotificationChannelExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "slack",
Summary: "Test a Slack webhook",
Value: map[string]any{"config": channelConfig("slack", slackChannelSpec())},
},
{
Name: "webhook",
Summary: "Test a webhook endpoint",
Value: map[string]any{"config": channelConfig("webhook", webhookChannelSpec())},
},
}
}

View File

@@ -0,0 +1,39 @@
package signozapiserver
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/stretchr/testify/require"
)
func TestNotificationChannelExamplesAreValidPayloads(t *testing.T) {
for _, ex := range createNotificationChannelExamples() {
t.Run("create/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var postable alertmanagertypes.PostableNotificationChannel
require.NoError(t, json.Unmarshal(raw, &postable))
require.NoError(t, postable.Validate())
})
}
for _, ex := range updateNotificationChannelExamples() {
t.Run("update/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var updatable alertmanagertypes.UpdatableNotificationChannel
require.NoError(t, json.Unmarshal(raw, &updatable))
require.NoError(t, updatable.Validate())
})
}
for _, ex := range testNotificationChannelExamples() {
t.Run("test/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var testable alertmanagertypes.TestableNotificationChannel
require.NoError(t, json.Unmarshal(raw, &testable))
require.NoError(t, testable.Validate())
})
}
}

View File

@@ -77,6 +77,7 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
Description: "This endpoint creates a dashboard in the v2 format that follows Perses spec.",
Request: new(dashboardtypes.PostableDashboardV2),
RequestContentType: "application/json",
RequestExamples: createDashboardExamples(),
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
@@ -200,6 +201,7 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
Request: new(dashboardtypes.UpdatableDashboardV2),
RequestContentType: "application/json",
RequestExamples: updateDashboardExamples(),
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
@@ -233,6 +235,7 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
// the array-of-ops schema visible to spec consumers; the runtime decoder
// parses JSON regardless of the request's actual Content-Type header.
RequestContentType: "application/json",
RequestExamples: patchDashboardExamples(),
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,

View File

@@ -0,0 +1,268 @@
package signozapiserver
import "github.com/SigNoz/signoz/pkg/http/handler"
// gridItem places a panel on the 12-column grid of a Grid layout.
func gridItem(panelID string, x, y, width, height int) map[string]any {
return map[string]any{
"x": x, "y": y, "width": width, "height": height,
"content": map[string]any{"$ref": "#/spec/panels/" + panelID},
}
}
func gridLayout(items ...map[string]any) map[string]any {
list := make([]any, 0, len(items))
for _, item := range items {
list = append(list, item)
}
return map[string]any{"kind": "Grid", "spec": map[string]any{"items": list}}
}
// builderQuery wraps a builder query spec in the panel query envelope.
// requestType is the result shape the panel renders: time_series, scalar or raw.
func builderQuery(requestType string, spec map[string]any) map[string]any {
return map[string]any{
"kind": requestType,
"spec": map[string]any{
"name": spec["name"],
"plugin": map[string]any{"kind": "signoz/BuilderQuery", "spec": spec},
},
}
}
func panel(name, description string, plugin map[string]any, queries ...map[string]any) map[string]any {
list := make([]any, 0, len(queries))
for _, q := range queries {
list = append(list, q)
}
return map[string]any{
"kind": "Panel",
"spec": map[string]any{
"display": map[string]any{"name": name, "description": description},
"links": []any{},
"plugin": plugin,
"queries": list,
},
}
}
func serviceNameGroupBy() []any {
return []any{map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"}}
}
// createDashboardExamples returns example payloads for the dashboard create
// endpoint. They cover a variable-driven service overview and a logs dashboard
// that pairs a query-less text panel with a table.
func createDashboardExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "service_overview",
Summary: "Service overview with a variable, a time series panel and a number panel",
Description: "A `$service` list variable fed by the observed `service.name` values filters both panels. The time series panel counts spans per service; the number panel renders a single scalar and turns red above 100 errors. `generateName` lets the server derive the name from the display name.",
Value: map[string]any{
"schemaVersion": "v6",
"generateName": true,
"tags": []any{map[string]any{"key": "team", "value": "platform"}},
"spec": map[string]any{
"display": map[string]any{"name": "Service Overview", "description": "Request rate and errors per service"},
"variables": []any{
map[string]any{
"kind": "ListVariable",
"spec": map[string]any{
"name": "service",
"display": map[string]any{"name": "Service"},
"allowMultiple": true,
"allowAllValue": true,
"sort": "alphabetical-asc",
"plugin": map[string]any{
"kind": "signoz/DynamicVariable",
"spec": map[string]any{"name": "service.name", "signal": "traces"},
},
},
},
},
"panels": map[string]any{
"request-rate": panel("Requests", "Spans per service",
map[string]any{
"kind": "signoz/TimeSeriesPanel",
"spec": map[string]any{
"formatting": map[string]any{"decimalPrecision": "0"},
"legend": map[string]any{"position": "bottom"},
},
},
builderQuery("time_series", map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "requests"}},
"filter": map[string]any{"expression": "service.name IN $service"},
"groupBy": serviceNameGroupBy(),
"legend": "{{service.name}}",
}),
),
"error-count": panel("Errors", "Spans with an error status",
map[string]any{
"kind": "signoz/NumberPanel",
"spec": map[string]any{
"thresholds": []any{
map[string]any{"value": 100, "operator": "above", "color": "#F1575F", "format": "background"},
},
},
},
builderQuery("scalar", map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
"filter": map[string]any{"expression": "has_error = true AND service.name IN $service"},
}),
),
},
"layouts": []any{gridLayout(
gridItem("request-rate", 0, 0, 8, 6),
gridItem("error-count", 8, 0, 4, 6),
)},
"duration": "1h",
"links": []any{},
},
},
},
{
Name: "logs_table_with_runbook",
Summary: "Logs dashboard with a text panel and a table panel",
Description: "A `signoz/TextPanel` renders markdown and must send `queries: []`. The table counts error logs per service as a scalar result, orders by the `errors` alias and colors cells above 1000 errors; the threshold names the column by its key `A` (the query name), not by the alias.",
Value: map[string]any{
"schemaVersion": "v6",
"name": "error-logs-by-service",
"tags": []any{},
"spec": map[string]any{
"display": map[string]any{"name": "Error Logs by Service", "description": ""},
"variables": []any{},
"panels": map[string]any{
"runbook": panel("Runbook", "What to do when errors spike",
map[string]any{
"kind": "signoz/TextPanel",
"spec": map[string]any{
"mode": "markdown",
"text": "## Error logs\n\n1. Find the service with the most errors in the table.\n2. Open its logs and group by `code.function`.",
"presentation": map[string]any{"textAlign": "left", "verticalAlign": "top"},
"headerOptions": map[string]any{"hide": false},
},
},
),
"errors-by-service": panel("Error logs per service", "",
map[string]any{
"kind": "signoz/TablePanel",
"spec": map[string]any{
"formatting": map[string]any{"decimalPrecision": "0"},
"thresholds": []any{
map[string]any{"columnName": "A", "value": 1000, "operator": "above", "color": "#F1575F", "format": "background"},
},
},
},
builderQuery("scalar", map[string]any{
"name": "A",
"signal": "logs",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
"groupBy": serviceNameGroupBy(),
"order": []any{map[string]any{"key": map[string]any{"name": "errors"}, "direction": "desc"}},
"limit": 20,
}),
),
},
"layouts": []any{gridLayout(
gridItem("runbook", 0, 0, 4, 6),
gridItem("errors-by-service", 4, 0, 8, 6),
)},
"links": []any{},
},
},
},
}
}
// updateDashboardExamples returns the example payload for the dashboard update
// endpoint: the full replacement shape, including the immutable name.
func updateDashboardExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "replace_dashboard",
Summary: "Replace a dashboard with a single CPU time series panel",
Description: "Update is a full replacement: every field is written as sent, so fetch the dashboard first and resend the unchanged parts. `name` must equal the dashboard's current name.",
Value: map[string]any{
"schemaVersion": "v6",
"name": "host-cpu",
"tags": []any{map[string]any{"key": "team", "value": "infra"}},
"spec": map[string]any{
"display": map[string]any{"name": "Host CPU", "description": "CPU time by state"},
"variables": []any{},
"panels": map[string]any{
"cpu-time": panel("CPU time", "Rate of system.cpu.time by state",
map[string]any{
"kind": "signoz/TimeSeriesPanel",
"spec": map[string]any{
"chartAppearance": map[string]any{"fillMode": "gradient"},
"legend": map[string]any{"position": "right"},
},
},
builderQuery("time_series", map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{map[string]any{
"metricName": "system.cpu.time",
"timeAggregation": "rate",
"spaceAggregation": "sum",
}},
"groupBy": []any{map[string]any{"name": "state", "fieldContext": "attribute", "fieldDataType": "string"}},
"legend": "{{state}}",
}),
),
},
"layouts": []any{gridLayout(gridItem("cpu-time", 0, 0, 12, 7))},
"duration": "3h",
"refreshInterval": "30s",
"links": []any{},
},
},
},
}
}
// patchDashboardExamples returns example payloads for the dashboard patch
// endpoint. The request body is the RFC 6902 operation array itself.
func patchDashboardExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "rename",
Summary: "Rename a dashboard",
Description: "Replaces the display name without resending anything else.",
Value: []any{
map[string]any{"op": "replace", "path": "/spec/display/name", "value": "Service Overview (production)"},
},
},
{
Name: "add_panel",
Summary: "Add a panel and place it",
Description: "Adding a panel takes two operations: one adds the panel under `/spec/panels/<id>`, the other appends a grid item that references it to the first layout. A panel that no layout places is accepted but not shown.",
Value: []any{
map[string]any{
"op": "add",
"path": "/spec/panels/error-rate",
"value": panel("Error count", "Errored spans per time bucket",
map[string]any{"kind": "signoz/TimeSeriesPanel", "spec": map[string]any{}},
builderQuery("time_series", map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "has_error = true"},
}),
),
},
map[string]any{
"op": "add",
"path": "/spec/layouts/0/spec/items/-",
"value": gridItem("error-rate", 0, 7, 12, 6),
},
},
},
}
}

View File

@@ -0,0 +1,49 @@
package signozapiserver
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
func TestDashboardExamplesAreValidPayloads(t *testing.T) {
for _, ex := range createDashboardExamples() {
t.Run("create/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var postable dashboardtypes.PostableDashboardV2
require.NoError(t, json.Unmarshal(raw, &postable))
})
}
var base dashboardtypes.UpdatableDashboardV2
for _, ex := range updateDashboardExamples() {
t.Run("update/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(raw, &base))
})
}
existing := &dashboardtypes.DashboardV2{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
Source: dashboardtypes.SourceUser,
DashboardV2MetadataBase: base.DashboardV2MetadataBase,
Name: base.Name,
Spec: base.Spec,
}
for _, ex := range patchDashboardExamples() {
t.Run("patch/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var patch dashboardtypes.PatchableDashboardV2
require.NoError(t, json.Unmarshal(raw, &patch))
_, err = patch.Apply(existing)
require.NoError(t, err)
})
}
}

View File

@@ -70,6 +70,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Description: "This endpoint returns a treemap visualization showing the proportional distribution of metrics by sample count or time series count",
Request: new(metricsexplorertypes.TreemapRequest),
RequestContentType: "application/json",
RequestExamples: treemapRequestExamples(),
Response: new(metricsexplorertypes.TreemapResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,

View File

@@ -0,0 +1,33 @@
package signozapiserver
import "github.com/SigNoz/signoz/pkg/http/handler"
// treemapRequestExamples returns example payloads for the metrics treemap
// endpoint, one per mode.
func treemapRequestExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "treemap_timeseries",
Summary: "Top metrics by time series count",
Description: "Sizes each metric by the number of distinct time series it produced in the time range, limited to the 50 largest. Only the `timeseries` array of the response is populated.",
Value: map[string]any{
"start": 1640995200000,
"end": 1640998800000,
"limit": 50,
"mode": "timeseries",
},
},
{
Name: "treemap_samples_filtered",
Summary: "Top metrics by sample count for one host",
Description: "Sizes each metric by the number of samples ingested, restricted with a query builder filter on a metric attribute. Only the `samples` array of the response is populated.",
Value: map[string]any{
"start": 1640995200000,
"end": 1640998800000,
"limit": 20,
"mode": "samples",
"filter": map[string]any{"expression": "host.name = 'web-1'"},
},
},
}
}

View File

@@ -0,0 +1,21 @@
package signozapiserver
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/types/metricsexplorertypes"
"github.com/stretchr/testify/require"
)
func TestTreemapExamplesAreValidPayloads(t *testing.T) {
for _, ex := range treemapRequestExamples() {
t.Run(ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var req metricsexplorertypes.TreemapRequest
require.NoError(t, json.Unmarshal(raw, &req))
require.NoError(t, req.Validate())
})
}
}

View File

@@ -48,6 +48,7 @@ func (provider *provider) addSavedViewRoutes(router *mux.Router) error {
Description: "Persists a saved view for the explore page. Returns the id of the created view.",
Request: new(savedviewtypes.PostableSavedView),
RequestContentType: "application/json",
RequestExamples: postableSavedViewExamples(),
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
@@ -102,6 +103,7 @@ func (provider *provider) addSavedViewRoutes(router *mux.Router) error {
Description: "Replaces a saved view's name and query.",
Request: new(savedviewtypes.UpdatableSavedView),
RequestContentType: "application/json",
RequestExamples: updatableSavedViewExamples(),
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,

View File

@@ -0,0 +1,176 @@
package signozapiserver
import "github.com/SigNoz/signoz/pkg/http/handler"
// postableSavedViewExamples returns example payloads attached to the saved view
// create endpoint. They cover each explorer source and both list and graph panels.
func postableSavedViewExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "traces_list",
Summary: "Traces list view with filter and columns",
Description: "A list panel over spans slower than 500ms in one service, ordered newest first, with three explorer columns.",
Value: map[string]any{
"name": "slow-checkout-traces",
"source": "traces",
"schemaVersion": "v2",
"spec": map[string]any{
"displayName": "Slow checkout traces",
"panelType": "list",
"requestType": "raw",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"filter": map[string]any{"expression": "service.name = 'checkoutservice' AND duration_nano > 500000000"},
"order": []any{map[string]any{"key": map[string]any{"name": "timestamp"}, "direction": "desc"}},
"limit": 100,
},
},
},
"selectedFields": []any{
map[string]any{"name": "service.name", "signal": "traces", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "name", "signal": "traces", "fieldContext": "span", "fieldDataType": "string"},
map[string]any{"name": "duration_nano", "signal": "traces", "fieldContext": "span", "fieldDataType": "float64"},
},
},
},
},
{
Name: "logs_list_generated_name",
Summary: "Logs list view with a generated name",
Description: "A list panel over error logs of one service. generateName lets the server derive the machine name from displayName; the display preferences set the row format and font size.",
Value: map[string]any{
"generateName": true,
"source": "logs",
"schemaVersion": "v2",
"spec": map[string]any{
"displayName": "Payment errors",
"panelType": "list",
"requestType": "raw",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "logs",
"filter": map[string]any{"expression": "service.name = 'paymentservice' AND severity_text = 'ERROR'"},
"order": []any{
map[string]any{"key": map[string]any{"name": "timestamp"}, "direction": "desc"},
map[string]any{"key": map[string]any{"name": "id"}, "direction": "desc"},
},
"limit": 100,
},
},
},
"selectedFields": []any{
map[string]any{"name": "service.name", "signal": "logs", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "severity_text", "signal": "logs", "fieldContext": "log", "fieldDataType": "string"},
},
"display": map[string]any{
"format": "list",
"fontSize": "small",
"maxLines": 2,
},
},
},
},
{
Name: "metrics_time_series",
Summary: "Metrics graph view",
Description: "A graph panel plotting the per-second request rate of a counter, summed across series at one minute resolution.",
Value: map[string]any{
"name": "http-request-rate",
"source": "metrics",
"schemaVersion": "v2",
"spec": map[string]any{
"displayName": "HTTP request rate",
"panelType": "graph",
"requestType": "time_series",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"stepInterval": 60,
"aggregations": []any{map[string]any{"metricName": "http_requests_total", "timeAggregation": "rate", "spaceAggregation": "sum"}},
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"}},
},
},
},
"selectedFields": []any{},
},
},
},
{
Name: "meter_time_series",
Summary: "Cost meter graph view",
Description: "A graph panel on the meter explorer. Meter views are queried as metrics with the builder spec source set to meter; the meter rolls up hourly, so the step is 3600 seconds.",
Value: map[string]any{
"name": "log-ingestion-volume",
"source": "meter",
"schemaVersion": "v2",
"spec": map[string]any{
"displayName": "Log ingestion volume",
"panelType": "graph",
"requestType": "time_series",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"source": "meter",
"stepInterval": 3600,
"aggregations": []any{map[string]any{"metricName": "signoz.meter.log.size", "timeAggregation": "increase", "spaceAggregation": "sum"}},
},
},
},
"selectedFields": []any{},
},
},
},
}
}
// updatableSavedViewExamples returns example payloads attached to the saved view
// update endpoint. The update replaces the whole spec; the machine name is immutable.
func updatableSavedViewExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "traces_list_widen_filter",
Summary: "Replace a traces list view",
Description: "Replaces the spec of an existing traces list view with a wider latency filter and one more column. Every field of spec must be sent again; the machine name stays as created.",
Value: map[string]any{
"source": "traces",
"schemaVersion": "v2",
"spec": map[string]any{
"displayName": "Slow checkout traces (over 250ms)",
"panelType": "list",
"requestType": "raw",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"filter": map[string]any{"expression": "service.name = 'checkoutservice' AND duration_nano > 250000000"},
"order": []any{map[string]any{"key": map[string]any{"name": "timestamp"}, "direction": "desc"}},
"limit": 100,
},
},
},
"selectedFields": []any{
map[string]any{"name": "service.name", "signal": "traces", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "name", "signal": "traces", "fieldContext": "span", "fieldDataType": "string"},
map[string]any{"name": "duration_nano", "signal": "traces", "fieldContext": "span", "fieldDataType": "float64"},
map[string]any{"name": "http.status_code", "signal": "traces", "fieldContext": "attribute", "fieldDataType": "string"},
},
},
},
},
}
}

View File

@@ -0,0 +1,30 @@
package signozapiserver
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
"github.com/stretchr/testify/require"
)
func TestSavedViewExamplesAreValidPayloads(t *testing.T) {
for _, ex := range postableSavedViewExamples() {
t.Run("create/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var postable savedviewtypes.PostableSavedView
require.NoError(t, json.Unmarshal(raw, &postable))
require.NoError(t, postable.Validate())
})
}
for _, ex := range updatableSavedViewExamples() {
t.Run("update/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var updatable savedviewtypes.UpdatableSavedView
require.NoError(t, json.Unmarshal(raw, &updatable))
require.NoError(t, updatable.Validate())
})
}
}

View File

@@ -47,9 +47,9 @@ const (
type DeprecatedGettableAlert struct {
*model.Alert
Status types.AlertStatus `json:"status"`
Receivers []string `json:"receivers"`
Fingerprint string `json:"fingerprint"`
Status types.AlertStatus `json:"status" description:"The alert state and the silences and inhibitions suppressing it."`
Receivers []string `json:"receivers" description:"The display names of the channels the alert is routed to."`
Fingerprint string `json:"fingerprint" description:"The alert fingerprint, a hash of its label set."`
}
type DeprecatedGettableAlerts = []*DeprecatedGettableAlert

View File

@@ -36,11 +36,11 @@ type Channel struct {
// DisplayName is the receiver name inside Data and what policies and rules
// reference, so it keeps the v1 wire tag and Name stays off the v1 contract.
Name string `json:"-" bun:"name"`
DisplayName string `json:"name" required:"true" bun:"display_name"`
DisplayName string `json:"name" required:"true" bun:"display_name" description:"The channel name. Alert rules and routing policies reference it."`
// TODO: type this as ChannelKind once v1 is gone.
Type string `json:"type" required:"true" bun:"type"`
Data string `json:"data" required:"true" bun:"data"`
OrgID string `json:"orgId" required:"true" bun:"org_id"`
Type string `json:"type" required:"true" bun:"type" description:"The channel kind, such as slack."`
Data string `json:"data" required:"true" bun:"data" description:"The channel configuration as a JSON document."`
OrgID string `json:"orgId" required:"true" bun:"org_id" description:"The organization the channel belongs to."`
// Spec is the v2 spec a read returns, of the kind Type names. A v2 write
// stores it as the caller wrote it and a v1 write derives it from the

View File

@@ -14,10 +14,10 @@ import (
// credentials and TLS settings come from the deployment's global config, so a
// channel can only choose recipients and body.
type ChannelEmailConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
To string `json:"to" required:"true"`
HTML valuer.UnsetOrNonEmptyString `json:"html,omitzero"`
Headers map[string]string `json:"headers,omitzero"`
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to false."`
To string `json:"to" required:"true" description:"The recipient addresses, comma separated."`
HTML valuer.UnsetOrNonEmptyString `json:"html,omitzero" description:"Alertmanager template for the HTML body; defaults to the Alertmanager email default."`
Headers map[string]string `json:"headers,omitzero" description:"Extra SMTP headers keyed by canonical MIME header name, such as Subject."`
}
func (c *ChannelEmailConfig) UnmarshalJSON(data []byte) error {

View File

@@ -8,10 +8,10 @@ import (
)
type ChannelGoogleChatConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero"`
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to false."`
WebhookURL string `json:"webhookUrl" required:"true" format:"password" description:"The Google Chat space webhook URL. A secret; returned by create, update and fetch by ID, never by list."`
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero" description:"Alertmanager template for the message title; defaults to the SigNoz Google Chat default."`
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero" description:"Alertmanager template for the message body; defaults to the SigNoz Google Chat default."`
}
func (c *ChannelGoogleChatConfig) UnmarshalJSON(data []byte) error {
@@ -71,11 +71,11 @@ func newChannelGoogleChatConfigFromReceiver(name string, receiver *Receiver) (Ch
type GoogleChatReceiverConfig struct {
config.NotifierConfig `yaml:",inline" json:",inline"`
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty" description:"HTTP client settings for the webhook call."`
WebhookURL *config.SecretURL `yaml:"webhook_url,omitempty" json:"webhook_url,omitempty"`
Title string `yaml:"title,omitempty" json:"title,omitempty"`
Text string `yaml:"text,omitempty" json:"text,omitempty"`
WebhookURL *config.SecretURL `yaml:"webhook_url,omitempty" json:"webhook_url,omitempty" description:"The Google Chat space webhook URL."`
Title string `yaml:"title,omitempty" json:"title,omitempty" description:"Alertmanager template for the message title."`
Text string `yaml:"text,omitempty" json:"text,omitempty" description:"Alertmanager template for the message body."`
}
var DefaultGoogleChatReceiverConfig = GoogleChatReceiverConfig{

View File

@@ -10,12 +10,12 @@ import (
)
type ChannelIncidentIOConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
URL string `json:"url" required:"true"`
Token string `json:"token" required:"true" format:"password"`
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
Metadata map[string]string `json:"metadata,omitzero"`
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to false."`
URL string `json:"url" required:"true" description:"The alert source HTTP alert events endpoint copied from its setup page."`
Token string `json:"token" required:"true" format:"password" description:"The alert source secret token, without the Bearer prefix. A secret; returned by create, update and fetch by ID, never by list."`
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero" description:"Alertmanager template for the alert title; defaults to the SigNoz incident.io default."`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero" description:"Alertmanager template for the alert description, rendered as markdown; defaults to the SigNoz incident.io default."`
Metadata map[string]string `json:"metadata,omitzero" description:"Extra event metadata keyed by name, merged over the group's common labels; values are Alertmanager templates."`
}
func (c *ChannelIncidentIOConfig) UnmarshalJSON(data []byte) error {
@@ -102,15 +102,15 @@ const (
type IncidentIOReceiverConfig struct {
config.NotifierConfig `yaml:",inline" json:",inline"`
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty" description:"HTTP client settings for the API call."`
URL string `yaml:"url,omitempty" json:"url,omitempty"`
Token config.Secret `yaml:"token,omitempty" json:"token,omitempty"`
Title string `yaml:"title,omitempty" json:"title,omitempty"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
URL string `yaml:"url,omitempty" json:"url,omitempty" description:"The alert source HTTP alert events endpoint."`
Token config.Secret `yaml:"token,omitempty" json:"token,omitempty" description:"The alert source secret token, without the Bearer prefix."`
Title string `yaml:"title,omitempty" json:"title,omitempty" description:"Alertmanager template for the alert title."`
Description string `yaml:"description,omitempty" json:"description,omitempty" description:"Alertmanager template for the alert description."`
// Metadata is merged into the event's metadata on top of the group's common
// labels (channel wins on key clash). Values are template-expanded.
Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty" description:"Extra event metadata keyed by name, merged over the group's common labels; values are Alertmanager templates."`
}
// send_resolved has no omitempty upstream, so a var default here is overwritten

View File

@@ -13,24 +13,22 @@ import (
)
type ChannelJiraConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
// Site is the Jira Cloud base URL, https://<site>.atlassian.net. Only Jira
// Cloud is supported; the REST base is derived from it.
Site string `json:"site" required:"true"`
Project string `json:"project" required:"true"`
IssueType string `json:"issueType" required:"true"`
Summary valuer.UnsetOrNonEmptyString `json:"summary,omitzero"`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
Priority string `json:"priority"`
Labels []string `json:"labels,omitzero"`
ResolveTransition string `json:"resolveTransition"`
ReopenTransition string `json:"reopenTransition"`
ReopenDuration valuer.UnsetOrNonEmptyString `json:"reopenDuration,omitzero"`
WontFixResolution string `json:"wontFixResolution"`
CustomFields map[string]any `json:"customFields,omitzero"`
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to false."`
Site string `json:"site" required:"true" description:"The base URL of the Jira Cloud site, for example https://example.atlassian.net. Only Jira Cloud is supported."`
Project string `json:"project" required:"true" description:"The key of the project issues are created in."`
IssueType string `json:"issueType" required:"true" description:"The issue type name, such as Bug."`
Summary valuer.UnsetOrNonEmptyString `json:"summary,omitzero" description:"Alertmanager template for the issue summary; defaults to the SigNoz Jira default."`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero" description:"Alertmanager template for the issue description, rendered as markdown; defaults to the SigNoz Jira default."`
Priority string `json:"priority" description:"The priority name assigned to new issues."`
Labels []string `json:"labels,omitzero" description:"Labels added to every issue."`
ResolveTransition string `json:"resolveTransition" description:"The transition applied to the issue when the alert resolves."`
ReopenTransition string `json:"reopenTransition" description:"The transition applied when an alert fires again on a resolved issue."`
ReopenDuration valuer.UnsetOrNonEmptyString `json:"reopenDuration,omitzero" description:"How long after resolution a firing alert reopens the issue instead of creating a new one, as a Prometheus duration in canonical form such as 3d; defaults to 3d."`
WontFixResolution string `json:"wontFixResolution" description:"The resolution name that stops an issue from being reopened."`
CustomFields map[string]any `json:"customFields,omitzero" description:"Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives."`
Email string `json:"email" required:"true"`
APIToken string `json:"apiToken" required:"true" format:"password"`
Email string `json:"email" required:"true" description:"The Atlassian account email used for basic auth."`
APIToken string `json:"apiToken" required:"true" format:"password" description:"The Atlassian API token paired with email. A secret; returned by create, update and fetch by ID, never by list."`
}
// UnmarshalJSON seeds send_resolved off, as JiraReceiverConfig does.
@@ -193,19 +191,19 @@ const (
type JiraReceiverConfig struct {
config.NotifierConfig `yaml:",inline"`
Site string `json:"site,omitempty" yaml:"site,omitempty"`
Project string `json:"project,omitempty" yaml:"project,omitempty"`
IssueType string `json:"issue_type,omitempty" yaml:"issue_type,omitempty"`
Summary string `json:"summary,omitempty" yaml:"summary,omitempty"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
Priority string `json:"priority,omitempty" yaml:"priority,omitempty"`
Labels []string `json:"labels,omitempty" yaml:"labels,omitempty"`
ResolveTransition string `json:"resolve_transition,omitempty" yaml:"resolve_transition,omitempty"`
ReopenTransition string `json:"reopen_transition,omitempty" yaml:"reopen_transition,omitempty"`
ReopenDuration model.Duration `json:"reopen_duration" yaml:"reopen_duration"`
WontFixResolution string `json:"wont_fix_resolution,omitempty" yaml:"wont_fix_resolution,omitempty"`
CustomFields map[string]any `json:"custom_fields,omitempty" yaml:"custom_fields,omitempty"`
HTTPConfig *commoncfg.HTTPClientConfig `json:"http_config,omitempty" yaml:"http_config,omitempty"`
Site string `json:"site,omitempty" yaml:"site,omitempty" description:"The Jira Cloud base URL."`
Project string `json:"project,omitempty" yaml:"project,omitempty" description:"The key of the project issues are created in."`
IssueType string `json:"issue_type,omitempty" yaml:"issue_type,omitempty" description:"The issue type name."`
Summary string `json:"summary,omitempty" yaml:"summary,omitempty" description:"Alertmanager template for the issue summary."`
Description string `json:"description,omitempty" yaml:"description,omitempty" description:"Alertmanager template for the issue description."`
Priority string `json:"priority,omitempty" yaml:"priority,omitempty" description:"The priority name assigned to new issues."`
Labels []string `json:"labels,omitempty" yaml:"labels,omitempty" description:"Labels added to every issue."`
ResolveTransition string `json:"resolve_transition,omitempty" yaml:"resolve_transition,omitempty" description:"The transition applied when the alert resolves."`
ReopenTransition string `json:"reopen_transition,omitempty" yaml:"reopen_transition,omitempty" description:"The transition applied when an alert fires again on a resolved issue."`
ReopenDuration model.Duration `json:"reopen_duration" yaml:"reopen_duration" description:"How long after resolution a firing alert reopens the issue; a Prometheus duration, at least 1m."`
WontFixResolution string `json:"wont_fix_resolution,omitempty" yaml:"wont_fix_resolution,omitempty" description:"The resolution name that stops an issue from being reopened."`
CustomFields map[string]any `json:"custom_fields,omitempty" yaml:"custom_fields,omitempty" description:"Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives."`
HTTPConfig *commoncfg.HTTPClientConfig `json:"http_config,omitempty" yaml:"http_config,omitempty" description:"HTTP client settings; basic auth carries the account email and API token."`
}
func (c *JiraReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {

View File

@@ -10,13 +10,12 @@ import (
// ChannelJSMOpsConfig carries no API URL: JSM Ops is a single global gateway
// keyed by the integration API key, which the notifier pins itself.
type ChannelJSMOpsConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
APIKey string `json:"apiKey" required:"true" format:"password"`
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero"`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
Priority string `json:"priority"`
// Tags is the comma-separated list JSM Ops attaches to the alert.
Tags valuer.UnsetOrNonEmptyString `json:"tags,omitzero"`
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to false."`
APIKey string `json:"apiKey" required:"true" format:"password" description:"The JSM Ops integration API key. A secret; returned by create, update and fetch by ID, never by list."`
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero" description:"Alertmanager template for the alert message; defaults to the SigNoz JSM Ops default."`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero" description:"Alertmanager template for the alert description; defaults to the SigNoz JSM Ops default."`
Priority string `json:"priority" description:"The alert priority, P1 to P5."`
Tags valuer.UnsetOrNonEmptyString `json:"tags,omitzero" description:"Comma-separated tags attached to the alert; defaults to signoz."`
}
func (c *ChannelJSMOpsConfig) UnmarshalJSON(data []byte) error {
@@ -108,13 +107,13 @@ const (
type JSMOpsReceiverConfig struct {
config.NotifierConfig `yaml:",inline" json:",inline"`
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty" description:"HTTP client settings for the API call."`
APIKey config.Secret `yaml:"api_key,omitempty" json:"api_key,omitempty"`
Message string `yaml:"message,omitempty" json:"message,omitempty"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Priority string `yaml:"priority,omitempty" json:"priority,omitempty"`
Tags string `yaml:"tags,omitempty" json:"tags,omitempty"`
APIKey config.Secret `yaml:"api_key,omitempty" json:"api_key,omitempty" description:"The JSM Ops integration API key."`
Message string `yaml:"message,omitempty" json:"message,omitempty" description:"Alertmanager template for the alert message."`
Description string `yaml:"description,omitempty" json:"description,omitempty" description:"Alertmanager template for the alert description."`
Priority string `yaml:"priority,omitempty" json:"priority,omitempty" description:"The alert priority, P1 to P5."`
Tags string `yaml:"tags,omitempty" json:"tags,omitempty" description:"Comma-separated tags attached to the alert."`
}
// send_resolved has no omitempty upstream, so a var default here is overwritten

View File

@@ -7,10 +7,10 @@ import (
)
type ChannelMSTeamsConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero"`
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to true."`
WebhookURL string `json:"webhookUrl" required:"true" format:"password" description:"The Teams incoming webhook URL. A secret; returned by create, update and fetch by ID, never by list."`
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero" description:"Alertmanager template for the card title; defaults to the Alertmanager Teams default."`
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero" description:"Alertmanager template for the card body; defaults to the Alertmanager Teams default."`
}
func (c *ChannelMSTeamsConfig) UnmarshalJSON(data []byte) error {

View File

@@ -7,14 +7,14 @@ import (
)
type ChannelOpsgenieConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
APIKey string `json:"apiKey" required:"true" format:"password"`
APIURL string `json:"apiUrl"`
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero"`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
Source valuer.UnsetOrNonEmptyString `json:"source,omitzero"`
Details map[string]string `json:"details,omitzero"`
Priority string `json:"priority"`
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to true."`
APIKey string `json:"apiKey" required:"true" format:"password" description:"The Opsgenie API integration key. A secret; returned by create, update and fetch by ID, never by list."`
APIURL string `json:"apiUrl" description:"The Opsgenie API host; empty uses the global Alertmanager default."`
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero" description:"Alertmanager template for the alert message; defaults to the Alertmanager Opsgenie default."`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero" description:"Alertmanager template for the alert description; defaults to the Alertmanager Opsgenie default."`
Source valuer.UnsetOrNonEmptyString `json:"source,omitzero" description:"Alertmanager template for the alert source; defaults to the Alertmanager Opsgenie default."`
Details map[string]string `json:"details,omitzero" description:"Custom alert details keyed by name, each an Alertmanager template."`
Priority string `json:"priority" description:"The alert priority, P1 to P5."`
}
func (c *ChannelOpsgenieConfig) UnmarshalJSON(data []byte) error {

View File

@@ -7,18 +7,18 @@ import (
)
type ChannelPagerdutyConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
RoutingKey string `json:"routingKey" required:"true" format:"password"`
URL string `json:"url"`
Source valuer.UnsetOrNonEmptyString `json:"source,omitzero"`
Client valuer.UnsetOrNonEmptyString `json:"client,omitzero"`
ClientURL valuer.UnsetOrNonEmptyString `json:"clientUrl,omitzero"`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
Severity string `json:"severity"`
Component string `json:"component"`
Group string `json:"group"`
Class string `json:"class"`
Details map[string]string `json:"details,omitzero"`
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to true."`
RoutingKey string `json:"routingKey" required:"true" format:"password" description:"The Events API v2 integration key. A secret; returned by create, update and fetch by ID, never by list."`
URL string `json:"url" description:"The Events API endpoint; empty uses the PagerDuty default."`
Source valuer.UnsetOrNonEmptyString `json:"source,omitzero" description:"Alertmanager template for the unique location of the affected system; defaults to client."`
Client valuer.UnsetOrNonEmptyString `json:"client,omitzero" description:"Alertmanager template for the client identifier; defaults to the Alertmanager PagerDuty default."`
ClientURL valuer.UnsetOrNonEmptyString `json:"clientUrl,omitzero" description:"Alertmanager template for the client URL; defaults to the Alertmanager PagerDuty default."`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero" description:"Alertmanager template for the incident summary; defaults to the Alertmanager PagerDuty default."`
Severity string `json:"severity" description:"The incident severity: critical, error, warning or info."`
Component string `json:"component" description:"The component of the source machine that is responsible for the event."`
Group string `json:"group" description:"The logical grouping of components the event belongs to."`
Class string `json:"class" description:"The class or type of the event."`
Details map[string]string `json:"details,omitzero" description:"Custom details keyed by name, each an Alertmanager template; defaults to the Alertmanager firing and resolved summary set."`
}
func (c *ChannelPagerdutyConfig) UnmarshalJSON(data []byte) error {

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