mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-27 11:30:41 +00:00
Compare commits
14 Commits
feat/scatt
...
test/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
697fff7fbf | ||
|
|
1e5d90fa65 | ||
|
|
07d387342b | ||
|
|
5515b36a85 | ||
|
|
3ed816ed74 | ||
|
|
2393165b13 | ||
|
|
50d5ccf739 | ||
|
|
61608dffc9 | ||
|
|
b4c44b80d6 | ||
|
|
babf696234 | ||
|
|
11e87a2892 | ||
|
|
d9f8755bc6 | ||
|
|
f14f7497b6 | ||
|
|
5d28d0b6a0 |
3810
docs/api/openapi.yml
3810
docs/api/openapi.yml
File diff suppressed because it is too large
Load Diff
@@ -128,7 +128,7 @@ test('TC-09 RouteTab switches the url', async ({ routing }) => {
|
||||
});
|
||||
```
|
||||
|
||||
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
|
||||
- `driver(name, build, dispose?)` adds a test-scoped fixture built on `authedPage`. `dispose` runs after the test on the same page, so a driver can delete what it seeded.
|
||||
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
|
||||
- A driver reuses helpers by calling them, never by reaching for another fixture.
|
||||
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -71,7 +71,6 @@ export const AIAssistantOpenSource = {
|
||||
Shortcut: 'shortcut',
|
||||
Cmdk: 'cmdk',
|
||||
TraceDetails: 'trace_details',
|
||||
BottomStrip: 'bottom_strip',
|
||||
} as const;
|
||||
export type AIAssistantOpenSource =
|
||||
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
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,
|
||||
@@ -44,14 +37,6 @@ 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;
|
||||
}
|
||||
@@ -72,7 +57,7 @@ function AskNoz(): JSX.Element | null {
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Noz size={16} />}
|
||||
onClick={handleOpen}
|
||||
onClick={(): void => openAIAssistant()}
|
||||
aria-label={
|
||||
showPendingBadge
|
||||
? `Ask Noz, ${pendingUserInputCount} ${
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
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';
|
||||
@@ -11,10 +6,6 @@ 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(),
|
||||
@@ -100,17 +91,6 @@ 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', () => {
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
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] =
|
||||
@@ -21,13 +14,7 @@ function SupportButton(): JSX.Element | null {
|
||||
}
|
||||
|
||||
const handleClick = (): void => {
|
||||
const needsCard = chatSupport === ChatSupportState.NeedsCard;
|
||||
void logEvent(BottomStripEvents.SupportClicked, {
|
||||
[BottomStripEventKeys.Type]: needsCard
|
||||
? BottomStripSupportType.AddCard
|
||||
: BottomStripSupportType.Pylon,
|
||||
});
|
||||
if (needsCard) {
|
||||
if (chatSupport === ChatSupportState.NeedsCard) {
|
||||
setIsAddCreditCardModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
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(),
|
||||
@@ -44,9 +37,6 @@ 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', () => {
|
||||
@@ -74,9 +64,6 @@ describe('SupportButton', () => {
|
||||
|
||||
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
|
||||
expect(window.Pylon).not.toHaveBeenCalled();
|
||||
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
|
||||
type: BottomStripSupportType.AddCard,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export enum BottomStripEvents {
|
||||
SupportClicked = 'Bottom Strip: Support clicked',
|
||||
}
|
||||
|
||||
export enum BottomStripEventKeys {
|
||||
Type = 'type',
|
||||
}
|
||||
|
||||
export enum BottomStripSupportType {
|
||||
Pylon = 'pylon',
|
||||
AddCard = 'add_card',
|
||||
}
|
||||
@@ -19,7 +19,6 @@ 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';
|
||||
@@ -55,17 +54,7 @@ function SavedViewsHeader({
|
||||
|
||||
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
|
||||
|
||||
const saveNewView = useSaveNewView(createView);
|
||||
const handleSave = useCallback(
|
||||
(displayName: string): Promise<boolean> =>
|
||||
saveNewView(
|
||||
displayName,
|
||||
modalMode === 'saveAsNew'
|
||||
? SavedViewCreatedFrom.SaveAsNew
|
||||
: SavedViewCreatedFrom.Header,
|
||||
),
|
||||
[saveNewView, modalMode],
|
||||
);
|
||||
const handleSave = useSaveNewView(createView);
|
||||
|
||||
const handleUpdate = useCallback(async (): Promise<void> => {
|
||||
if (view && (await updateView(view))) {
|
||||
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
@@ -36,7 +35,7 @@ function SavedViewsList({
|
||||
search: string;
|
||||
}): JSX.Element {
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { selectView, clearView, resetView, createView, isSaving } =
|
||||
const { selectView, clearView, createView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
const saveNewView = useSaveNewView(createView);
|
||||
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
|
||||
@@ -103,10 +102,10 @@ function SavedViewsList({
|
||||
return;
|
||||
}
|
||||
if (pendingAction.view.id === viewKey) {
|
||||
resetView();
|
||||
clearView();
|
||||
}
|
||||
setPendingAction(null);
|
||||
}, [pendingAction, deleteView, viewKey, resetView]);
|
||||
}, [pendingAction, deleteView, viewKey, clearView]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -142,9 +141,7 @@ function SavedViewsList({
|
||||
<SavedViewsEmptyState
|
||||
isSearching={search.trim().length > 0}
|
||||
isSaving={isSaving}
|
||||
onSave={(displayName): Promise<boolean> =>
|
||||
saveNewView(displayName, SavedViewCreatedFrom.EmptyState)
|
||||
}
|
||||
onSave={saveNewView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -19,15 +19,11 @@ 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;
|
||||
@@ -89,38 +85,20 @@ 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(updateStatus));
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
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(deleteStatus));
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
);
|
||||
return requests;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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',
|
||||
}
|
||||
@@ -2,15 +2,14 @@ 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, from: SavedViewCreatedFrom) => Promise<boolean> {
|
||||
): (displayName: string) => Promise<boolean> {
|
||||
return useCallback(
|
||||
async (displayName: string, from: SavedViewCreatedFrom): Promise<boolean> => {
|
||||
if (!(await createView(displayName, from))) {
|
||||
async (displayName: string): Promise<boolean> => {
|
||||
if (!(await createView(displayName))) {
|
||||
return false;
|
||||
}
|
||||
toast.success('View created', {
|
||||
|
||||
@@ -17,7 +17,6 @@ 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';
|
||||
@@ -31,18 +30,12 @@ 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
|
||||
@@ -57,8 +50,6 @@ 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({
|
||||
@@ -116,16 +107,13 @@ export function useSavedViewActions(
|
||||
}
|
||||
}
|
||||
selectView(view);
|
||||
logSavedViewEvent(SavedViewEvents.ChangesDiscarded, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
},
|
||||
[optionsDataSource, preferencesSlice, selectView, logSavedViewEvent],
|
||||
[optionsDataSource, preferencesSlice, selectView],
|
||||
);
|
||||
|
||||
// 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 resetView = useCallback((): void => {
|
||||
const clearView = useCallback((): void => {
|
||||
clearLastUsedView();
|
||||
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
@@ -133,18 +121,8 @@ 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,
|
||||
from: SavedViewCreatedFrom,
|
||||
): Promise<string | undefined> => {
|
||||
async (displayName: string): Promise<string | undefined> => {
|
||||
try {
|
||||
const { data } = await createSavedView({
|
||||
data: {
|
||||
@@ -159,10 +137,6 @@ 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(
|
||||
@@ -184,7 +158,6 @@ export function useSavedViewActions(
|
||||
stagedQuery,
|
||||
currentQuery,
|
||||
setLastUsedView,
|
||||
logSavedViewEvent,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -200,9 +173,6 @@ export function useSavedViewActions(
|
||||
},
|
||||
});
|
||||
await invalidateViews(view.id);
|
||||
logSavedViewEvent(SavedViewEvents.ViewUpdated, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -215,14 +185,13 @@ export function useSavedViewActions(
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateSavedView, source, toCurrentSpec, invalidateViews, logSavedViewEvent],
|
||||
[updateSavedView, source, toCurrentSpec, invalidateViews],
|
||||
);
|
||||
|
||||
return {
|
||||
selectView,
|
||||
revertView,
|
||||
clearView,
|
||||
resetView,
|
||||
createView,
|
||||
updateView,
|
||||
isSaving: isCreating || isUpdating,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -22,11 +22,9 @@ 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(
|
||||
@@ -44,7 +42,6 @@ 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 } =
|
||||
@@ -58,11 +55,8 @@ export function useSavedViewRowActions(
|
||||
});
|
||||
copyToClipboard(getAbsoluteUrl(path));
|
||||
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.LinkCopied, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
},
|
||||
[pathname, copyToClipboard, logSavedViewEvent],
|
||||
[pathname, copyToClipboard],
|
||||
);
|
||||
|
||||
const copyViewId = useCallback(
|
||||
@@ -89,16 +83,13 @@ 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, logSavedViewEvent],
|
||||
[updateSavedView, source, queryClient],
|
||||
);
|
||||
|
||||
// The deleted view's own query is left alone: refetching it would fail
|
||||
@@ -109,16 +100,13 @@ 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, logSavedViewEvent],
|
||||
[deleteSavedView, source, queryClient],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,8 +10,6 @@ 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<
|
||||
@@ -53,12 +51,7 @@ export interface UseSavedViewActionsResult {
|
||||
selectView: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
revertView: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
clearView: () => void;
|
||||
/** Clears the open view without logging it, for flows that are not a clear. */
|
||||
resetView: () => void;
|
||||
createView: (
|
||||
displayName: string,
|
||||
from: SavedViewCreatedFrom,
|
||||
) => Promise<string | undefined>;
|
||||
createView: (displayName: string) => Promise<string | undefined>;
|
||||
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import uPlot, { AlignedData, Options } from 'uplot';
|
||||
|
||||
import { usePlotContext } from '../../context/PlotContext';
|
||||
import { UPlotChartProps } from '../types';
|
||||
import { prepareAlignedData } from './utils';
|
||||
import { prepareAlignedData, setInstanceHandle } from './utils';
|
||||
|
||||
/**
|
||||
* Check if dimensions have changed
|
||||
@@ -58,6 +58,7 @@ export default function UPlotChart({
|
||||
onDestroy?.(plotInstanceRef.current);
|
||||
plotInstanceRef.current.destroy();
|
||||
plotInstanceRef.current = null;
|
||||
setInstanceHandle(containerRef.current, null);
|
||||
setPlotContextInitialState({ uPlotInstance: null });
|
||||
plotRef?.(null);
|
||||
}
|
||||
@@ -101,6 +102,7 @@ export default function UPlotChart({
|
||||
});
|
||||
|
||||
plotInstanceRef.current = plot;
|
||||
setInstanceHandle(containerRef.current, plot);
|
||||
}, [
|
||||
config,
|
||||
data,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { applySpanGapsToAlignedData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { AlignedData } from 'uplot';
|
||||
import uPlot, { AlignedData } from 'uplot';
|
||||
|
||||
export function prepareAlignedData({
|
||||
data,
|
||||
@@ -14,3 +14,22 @@ export function prepareAlignedData({
|
||||
? applySpanGapsToAlignedData(data as AlignedData, seriesSpanGaps)
|
||||
: (data as AlignedData);
|
||||
}
|
||||
|
||||
/** A chart container carrying its live uPlot instance, for tests and debugging. */
|
||||
export interface UPlotHostElement extends HTMLDivElement {
|
||||
__uplot?: uPlot;
|
||||
}
|
||||
|
||||
export function setInstanceHandle(
|
||||
node: HTMLDivElement | null,
|
||||
plot: uPlot | null,
|
||||
): void {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (plot) {
|
||||
(node as UPlotHostElement).__uplot = plot;
|
||||
} else {
|
||||
delete (node as UPlotHostElement).__uplot;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ function AddColumnDropdown({
|
||||
value={field.name}
|
||||
isSelected={selectedNames.has(field.name)}
|
||||
onSelect={(): void => onToggle(field)}
|
||||
data-testid="list-columns-suggestion"
|
||||
data-testid={`list-columns-suggestion-${field.name}`}
|
||||
>
|
||||
{field.name}
|
||||
</ComboboxItem>
|
||||
|
||||
@@ -37,7 +37,12 @@ function SortableColumnChip({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={styles.chip}>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={styles.chip}
|
||||
data-testid={`list-column-chip-${name}`}
|
||||
>
|
||||
<span {...listeners} className={styles.grip}>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -61,7 +66,7 @@ function SortableColumnChip({
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={`Remove ${name}`}
|
||||
testId="list-column-remove"
|
||||
testId={`list-column-remove-${name}`}
|
||||
onClick={(): void => onRemove(name)}
|
||||
>
|
||||
<X size={12} />
|
||||
|
||||
@@ -88,6 +88,7 @@ function PreviewPane(props: PreviewPaneProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.preview, { [styles.previewStatic]: !!staticDraft })}
|
||||
data-testid="preview-pane"
|
||||
>
|
||||
{query && !query.hideHeader && (
|
||||
<div className={styles.header}>
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface ResizableHeaderProps extends Omit<
|
||||
> {
|
||||
width?: number;
|
||||
onResize?: (e: SyntheticEvent<Element>, data: ResizeCallbackData) => void;
|
||||
/** Column key, used only to give the drag grip a stable test handle. */
|
||||
columnKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,6 +29,7 @@ export interface ResizableHeaderProps extends Omit<
|
||||
function ResizableHeader({
|
||||
width,
|
||||
onResize,
|
||||
columnKey,
|
||||
...restProps
|
||||
}: ResizableHeaderProps): JSX.Element {
|
||||
const handle = useMemo(
|
||||
@@ -34,13 +37,14 @@ function ResizableHeader({
|
||||
<span
|
||||
className={styles.handle}
|
||||
role="presentation"
|
||||
data-testid={columnKey ? `column-resize-${columnKey}` : undefined}
|
||||
// Stop the grip's click from reaching the column sorter underneath.
|
||||
// The grip is a pointer-only resize affordance, not keyboard-actionable.
|
||||
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
[columnKey],
|
||||
);
|
||||
|
||||
if (!width || !onResize) {
|
||||
@@ -64,6 +68,7 @@ function ResizableHeader({
|
||||
ResizableHeader.defaultProps = {
|
||||
width: undefined,
|
||||
onResize: undefined,
|
||||
columnKey: undefined,
|
||||
};
|
||||
|
||||
export default ResizableHeader;
|
||||
|
||||
@@ -132,6 +132,7 @@ export function useResizableColumns<T>({
|
||||
onHeaderCell: (): ResizableHeaderProps => ({
|
||||
width,
|
||||
onResize: key && width ? handleResize(key) : undefined,
|
||||
columnKey: key,
|
||||
}),
|
||||
} as Column<T>;
|
||||
}),
|
||||
|
||||
@@ -104,7 +104,9 @@ function PanelHeader(props: PanelHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.header, 'panel-drag-handle')}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Typography.Text className={styles.headerTitle}>{name}</Typography.Text>
|
||||
<Typography.Text className={styles.headerTitle} data-testid="panel-title">
|
||||
{name}
|
||||
</Typography.Text>
|
||||
{description && (
|
||||
<Tooltip title={description}>
|
||||
<Info
|
||||
|
||||
@@ -99,7 +99,7 @@ export const ListPanelAddColumnOpen: Story = {
|
||||
timeout: 10_000,
|
||||
}),
|
||||
);
|
||||
await screen.findAllByTestId('list-columns-suggestion');
|
||||
await screen.findAllByTestId(/^list-columns-suggestion-/);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
.ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box
|
||||
) {
|
||||
@include custom-scrollbar;
|
||||
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
|
||||
@@ -19,6 +20,7 @@ interface MessagesAccordionProps extends Pick<
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
messages: ThreadMessage[];
|
||||
raw?: RawMessagesValue;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
@@ -26,6 +28,7 @@ function MessagesAccordion({
|
||||
icon,
|
||||
title,
|
||||
messages,
|
||||
raw,
|
||||
testId,
|
||||
messageType,
|
||||
canLinkToToolCall,
|
||||
@@ -33,8 +36,10 @@ 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 (messages.length === 0) {
|
||||
if (!canFormat && !raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -61,12 +66,12 @@ function MessagesAccordion({
|
||||
{title}
|
||||
</span>
|
||||
),
|
||||
extra: isOpen && (
|
||||
extra: isOpen && canFormat && (
|
||||
// The view toggle lives in the header; its clicks must not fold the panel.
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<span onClick={stopToggle}>
|
||||
<ThreadViewToggle
|
||||
value={view}
|
||||
value={effectiveView}
|
||||
onChange={setView}
|
||||
testId={`${testId}-view-toggle`}
|
||||
/>
|
||||
@@ -74,8 +79,9 @@ function MessagesAccordion({
|
||||
),
|
||||
children: (
|
||||
<MessagesSection
|
||||
view={view}
|
||||
view={effectiveView}
|
||||
messages={messages}
|
||||
raw={raw}
|
||||
testId={`${testId}-body`}
|
||||
messageType={messageType}
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
@@ -89,4 +95,8 @@ function MessagesAccordion({
|
||||
);
|
||||
}
|
||||
|
||||
MessagesAccordion.defaultProps = {
|
||||
raw: undefined,
|
||||
};
|
||||
|
||||
export default MessagesAccordion;
|
||||
|
||||
@@ -3,10 +3,7 @@ import { ArrowDownRight, ArrowUpRight } from '@signozhq/icons';
|
||||
|
||||
import { MessageType } from '../../AIThreadMessage/types';
|
||||
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
|
||||
import {
|
||||
getSpanMessages,
|
||||
hasFormattedMessages,
|
||||
} from '../../utils/spanMessages';
|
||||
import { getSpanMessages, hasAnyMessages } from '../../utils/spanMessages';
|
||||
import { ThreadSpan } from '../../TraceDetailsThread/types';
|
||||
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
|
||||
import MessagesAccordion from './MessagesAccordion';
|
||||
@@ -28,7 +25,7 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
|
||||
toolCallKeys,
|
||||
);
|
||||
|
||||
if (!hasFormattedMessages(data)) {
|
||||
if (!hasAnyMessages(data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -38,6 +35,7 @@ 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}
|
||||
@@ -47,6 +45,7 @@ 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}
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
border-radius: 50%;
|
||||
background: var(--accent-forest);
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
// Truncating text inside a badge (service name, status message).
|
||||
|
||||
@@ -3,7 +3,8 @@ import AIThreadMessage, {
|
||||
} from '../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadMessage } from '../AIThreadMessage/types';
|
||||
import { ThreadView } from '../TraceDetailsThread/types';
|
||||
import JsonBlock from '../AIThreadMessage/JsonBlock';
|
||||
import RawMessages from './RawMessages';
|
||||
import { RawMessagesValue } from '../utils/spanMessages';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
@@ -16,6 +17,7 @@ interface MessagesSectionProps extends MessageProps {
|
||||
title?: string;
|
||||
view: ThreadView;
|
||||
messages: ThreadMessage[];
|
||||
raw?: RawMessagesValue;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
@@ -23,19 +25,21 @@ function MessagesSection({
|
||||
title,
|
||||
view,
|
||||
messages,
|
||||
raw,
|
||||
testId,
|
||||
messageType,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: MessagesSectionProps): JSX.Element | null {
|
||||
if (messages.length === 0) {
|
||||
const isFormatted = view === ThreadView.Formatted;
|
||||
if (isFormatted ? messages.length === 0 : !raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.section} data-testid={testId}>
|
||||
{title && <div className={styles.sectionTitle}>{title}</div>}
|
||||
{view === ThreadView.Formatted ? (
|
||||
{isFormatted ? (
|
||||
<div className={styles.messages}>
|
||||
{messages.map((message, index) => (
|
||||
<AIThreadMessage
|
||||
@@ -50,7 +54,7 @@ function MessagesSection({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<JsonBlock data={messages} />
|
||||
<RawMessages value={raw as RawMessagesValue} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
@@ -58,6 +62,7 @@ function MessagesSection({
|
||||
|
||||
MessagesSection.defaultProps = {
|
||||
title: undefined,
|
||||
raw: undefined,
|
||||
};
|
||||
|
||||
export default MessagesSection;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
@@ -28,6 +28,12 @@
|
||||
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);
|
||||
|
||||
@@ -6,7 +6,11 @@ import { ThreadSpan, ThreadView } from '../TraceDetailsThread/types';
|
||||
import JsonBlock from '../AIThreadMessage/JsonBlock';
|
||||
import { MessageType } from '../AIThreadMessage/types';
|
||||
import MessagesSection from './MessagesSection';
|
||||
import { getSpanMessages, hasFormattedMessages } from '../utils/spanMessages';
|
||||
import {
|
||||
getSpanMessages,
|
||||
hasFormattedMessages,
|
||||
hasRawMessages,
|
||||
} from '../utils/spanMessages';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
@@ -28,8 +32,9 @@ function SpanMessages({
|
||||
onToolLinkClick,
|
||||
}: SpanMessagesProps): JSX.Element {
|
||||
const data = useMemo(() => getSpanMessages(span), [span]);
|
||||
const isFormatted = view === ThreadView.Formatted;
|
||||
|
||||
if (!hasFormattedMessages(data)) {
|
||||
if (isFormatted ? !hasFormattedMessages(data) : !hasRawMessages(data)) {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.missing, className)}
|
||||
@@ -40,11 +45,11 @@ function SpanMessages({
|
||||
);
|
||||
}
|
||||
|
||||
if (view === ThreadView.Json) {
|
||||
if (!isFormatted) {
|
||||
return (
|
||||
<div className={cx(styles.root, className)} data-testid="span-messages-json">
|
||||
<JsonBlock
|
||||
data={{ input: data.formattedInput, output: data.formattedOutput }}
|
||||
data={{ input: data.rawInput ?? [], output: data.rawOutput ?? [] }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -55,6 +60,7 @@ function SpanMessages({
|
||||
<MessagesSection
|
||||
view={view}
|
||||
messages={data.formattedInput}
|
||||
raw={data.rawInput}
|
||||
messageType={MessageType.Input}
|
||||
testId="span-messages-input"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
@@ -63,6 +69,7 @@ function SpanMessages({
|
||||
<MessagesSection
|
||||
view={view}
|
||||
messages={data.formattedOutput}
|
||||
raw={data.rawOutput}
|
||||
messageType={MessageType.Output}
|
||||
testId="span-messages-output"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
|
||||
@@ -48,36 +48,41 @@ describe('SpanMessages', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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} />);
|
||||
it('shows the empty state in JSON view when there are no raw messages', () => {
|
||||
render(<SpanMessages span={makeSpan({})} view={ThreadView.Json} />);
|
||||
|
||||
expect(screen.getByTestId('span-messages-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders formatted messages in JSON view, ignoring the raw attribute', () => {
|
||||
it('renders raw messages in JSON view', () => {
|
||||
const span = makeSpan({
|
||||
formattedInput: [
|
||||
{ role: 'user', parts: [{ type: 'text', content: 'formatted-text' }] },
|
||||
],
|
||||
attributes: {
|
||||
[GEN_AI_KEYS.inputMessages]: '[{"role":"user","content":"raw-text"}]',
|
||||
},
|
||||
attributes: { [GEN_AI_KEYS.outputMessages]: '[{"role":"assistant"}]' },
|
||||
});
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Json} />);
|
||||
|
||||
expect(screen.getByTestId('span-messages-json')).toBeInTheDocument();
|
||||
expect(screen.getByText(/formatted-text/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/raw-text/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens every key in JSON view', () => {
|
||||
const span = makeSpan({
|
||||
attributes: {
|
||||
[GEN_AI_KEYS.inputMessages]: '[{"role":"user","content":"visible-text"}]',
|
||||
},
|
||||
});
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Json} />);
|
||||
|
||||
expect(screen.getByText(/visible-text/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses tall JSON behind Show more in JSON view', () => {
|
||||
const span = makeSpan({
|
||||
formattedInput: [{ role: 'user', parts: [{ type: 'text', content: 'hi' }] }],
|
||||
attributes: {
|
||||
[GEN_AI_KEYS.inputMessages]: JSON.stringify([
|
||||
{ role: 'user', content: 'hi' },
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Json} />);
|
||||
|
||||
@@ -99,12 +99,10 @@ 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);
|
||||
|
||||
@@ -22,8 +22,7 @@ function TraceDetailsTabs(): JSX.Element {
|
||||
const [tab, setTab] = useTraceDetailsTab();
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
// Pushed so the thread's "Go back" returns to the previous tab, not the previous page.
|
||||
void setTab(value as TraceDetailsTab, { history: 'push' });
|
||||
void setTab(value as TraceDetailsTab);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 { fireEvent, render, screen, userEvent } from 'tests/test-utils';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import TraceDetailsHeader from '../TraceDetailsHeader';
|
||||
import { useTraceSummary } from '../useTraceSummary';
|
||||
@@ -98,22 +100,6 @@ 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', () => {
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-20) var(--spacing-24);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-16);
|
||||
text-align: center;
|
||||
max-width: 428px;
|
||||
}
|
||||
|
||||
.noDataImg {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
.emoji {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--periscope-font-size-medium);
|
||||
font-weight: var(--font-weight-medium);
|
||||
.paragraphs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--paragraph-base-500-line-height);
|
||||
}
|
||||
|
||||
.highlight {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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';
|
||||
@@ -11,28 +10,32 @@ import styles from './ThreadNoAiSpans.module.scss';
|
||||
function ThreadNoAiSpans(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.root} data-testid="thread-empty">
|
||||
<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 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ function TraceDetailsThread(): JSX.Element {
|
||||
spansAboveFirstPage,
|
||||
isLoading,
|
||||
isError,
|
||||
isNotFound,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
isFetchingNextPage,
|
||||
@@ -53,7 +52,6 @@ function TraceDetailsThread(): JSX.Element {
|
||||
interestedSpanId,
|
||||
spans,
|
||||
isLoading,
|
||||
isNotFound,
|
||||
);
|
||||
const interestedSpanIndex = spans.findIndex(
|
||||
(span) => span.span_id === interestedSpanId,
|
||||
|
||||
@@ -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,7 +154,6 @@ 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 () => {
|
||||
@@ -163,6 +162,5 @@ describe('useTraceThread', () => {
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
expect(mockGetTraceThread).toHaveBeenCalledTimes(4);
|
||||
expect(result.current.isNotFound).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,8 +25,6 @@ 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;
|
||||
@@ -101,7 +99,6 @@ export function useTraceThread({
|
||||
spansAboveFirstPage,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
isNotFound: isNotFoundError(query.error),
|
||||
hasNextPage: !!query.hasNextPage,
|
||||
hasPreviousPage: !!query.hasPreviousPage,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
|
||||
@@ -28,7 +28,6 @@ export function getInterestedSpanStatus(
|
||||
interestedSpanId: string | undefined,
|
||||
spans: ThreadSpan[],
|
||||
isLoading: boolean,
|
||||
isNotFound: boolean,
|
||||
): InterestedSpanStatus {
|
||||
if (!interestedSpanId) {
|
||||
return InterestedSpanStatus.None;
|
||||
@@ -36,12 +35,8 @@ export function getInterestedSpanStatus(
|
||||
if (isLoading) {
|
||||
return InterestedSpanStatus.Loading;
|
||||
}
|
||||
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 InterestedSpanStatus.NotFound;
|
||||
}
|
||||
return spans.some((span) => span.span_id === interestedSpanId)
|
||||
? InterestedSpanStatus.Found
|
||||
|
||||
@@ -60,4 +60,4 @@ export enum TraceDetailsTab {
|
||||
}
|
||||
|
||||
export const LLM_OBSERVABILITY_DOCS_URL =
|
||||
'https://signoz.io/docs/ai-observability/';
|
||||
'https://signoz.io/docs/llm-observability/';
|
||||
|
||||
@@ -1,19 +1,49 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,6 @@ 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,
|
||||
@@ -225,7 +224,6 @@ 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,
|
||||
@@ -311,7 +309,6 @@ 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,
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
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())},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,6 @@ 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,
|
||||
@@ -201,7 +200,6 @@ 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,
|
||||
@@ -235,7 +233,6 @@ 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,
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
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),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,6 @@ 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,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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'"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,6 @@ 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,
|
||||
@@ -103,7 +102,6 @@ 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,
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -47,9 +47,9 @@ const (
|
||||
|
||||
type DeprecatedGettableAlert struct {
|
||||
*model.Alert
|
||||
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."`
|
||||
Status types.AlertStatus `json:"status"`
|
||||
Receivers []string `json:"receivers"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
type DeprecatedGettableAlerts = []*DeprecatedGettableAlert
|
||||
|
||||
@@ -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" description:"The channel name. Alert rules and routing policies reference it."`
|
||||
DisplayName string `json:"name" required:"true" bun:"display_name"`
|
||||
// TODO: type this as ChannelKind once v1 is gone.
|
||||
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."`
|
||||
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"`
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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" 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."`
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
To string `json:"to" required:"true"`
|
||||
HTML valuer.UnsetOrNonEmptyString `json:"html,omitzero"`
|
||||
Headers map[string]string `json:"headers,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelEmailConfig) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
)
|
||||
|
||||
type ChannelGoogleChatConfig struct {
|
||||
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."`
|
||||
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"`
|
||||
}
|
||||
|
||||
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" description:"HTTP client settings for the webhook call."`
|
||||
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,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."`
|
||||
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"`
|
||||
}
|
||||
|
||||
var DefaultGoogleChatReceiverConfig = GoogleChatReceiverConfig{
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
)
|
||||
|
||||
type ChannelIncidentIOConfig struct {
|
||||
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."`
|
||||
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"`
|
||||
}
|
||||
|
||||
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" description:"HTTP client settings for the API call."`
|
||||
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,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."`
|
||||
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"`
|
||||
// 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" description:"Extra event metadata keyed by name, merged over the group's common labels; values are Alertmanager templates."`
|
||||
Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// send_resolved has no omitempty upstream, so a var default here is overwritten
|
||||
|
||||
@@ -13,22 +13,24 @@ import (
|
||||
)
|
||||
|
||||
type ChannelJiraConfig struct {
|
||||
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."`
|
||||
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"`
|
||||
|
||||
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."`
|
||||
Email string `json:"email" required:"true"`
|
||||
APIToken string `json:"apiToken" required:"true" format:"password"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON seeds send_resolved off, as JiraReceiverConfig does.
|
||||
@@ -191,19 +193,19 @@ const (
|
||||
type JiraReceiverConfig struct {
|
||||
config.NotifierConfig `yaml:",inline"`
|
||||
|
||||
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."`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (c *JiraReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
|
||||
@@ -10,12 +10,13 @@ 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" 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."`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (c *ChannelJSMOpsConfig) UnmarshalJSON(data []byte) error {
|
||||
@@ -107,13 +108,13 @@ const (
|
||||
type JSMOpsReceiverConfig struct {
|
||||
config.NotifierConfig `yaml:",inline" json:",inline"`
|
||||
|
||||
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty" description:"HTTP client settings for the API call."`
|
||||
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,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."`
|
||||
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"`
|
||||
}
|
||||
|
||||
// send_resolved has no omitempty upstream, so a var default here is overwritten
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
)
|
||||
|
||||
type ChannelMSTeamsConfig struct {
|
||||
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."`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (c *ChannelMSTeamsConfig) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -7,14 +7,14 @@ import (
|
||||
)
|
||||
|
||||
type ChannelOpsgenieConfig struct {
|
||||
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."`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (c *ChannelOpsgenieConfig) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -7,18 +7,18 @@ import (
|
||||
)
|
||||
|
||||
type ChannelPagerdutyConfig struct {
|
||||
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."`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (c *ChannelPagerdutyConfig) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -7,43 +7,43 @@ import (
|
||||
)
|
||||
|
||||
type ChannelSlackConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to false."`
|
||||
APIURL string `json:"apiUrl" required:"true" format:"password" description:"The Slack incoming webhook URL. A secret; returned by create, update and fetch by ID, never by list."`
|
||||
Channel string `json:"channel" description:"The channel or user to post to, such as #alerts; empty uses the webhook default."`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero" description:"Alertmanager template for the message title; defaults to the Alertmanager Slack default."`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero" description:"Alertmanager template for the message body; defaults to the Alertmanager Slack default."`
|
||||
Color valuer.UnsetOrNonEmptyString `json:"color,omitzero" description:"Alertmanager template for the attachment color; defaults to the Alertmanager Slack default."`
|
||||
TitleLink valuer.UnsetOrNonEmptyString `json:"titleLink,omitzero" description:"Alertmanager template for the link the title opens; defaults to the Alertmanager Slack default."`
|
||||
Pretext valuer.UnsetOrNonEmptyString `json:"pretext,omitzero" description:"Alertmanager template for the text shown above the attachment; defaults to the Alertmanager Slack default."`
|
||||
Fallback valuer.UnsetOrNonEmptyString `json:"fallback,omitzero" description:"Alertmanager template for the plain-text summary shown where attachments cannot render; defaults to the Alertmanager Slack default."`
|
||||
Footer valuer.UnsetOrNonEmptyString `json:"footer,omitzero" description:"Alertmanager template for the attachment footer; defaults to the Alertmanager Slack default."`
|
||||
Fields []ChannelSlackField `json:"fields,omitzero" description:"Attachment fields appended to the message; each requires title and value."`
|
||||
Actions []ChannelSlackAction `json:"actions,omitzero" description:"Attachment buttons; each requires type and text plus url or name."`
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
APIURL string `json:"apiUrl" required:"true" format:"password"`
|
||||
Channel string `json:"channel"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero"`
|
||||
Color valuer.UnsetOrNonEmptyString `json:"color,omitzero"`
|
||||
TitleLink valuer.UnsetOrNonEmptyString `json:"titleLink,omitzero"`
|
||||
Pretext valuer.UnsetOrNonEmptyString `json:"pretext,omitzero"`
|
||||
Fallback valuer.UnsetOrNonEmptyString `json:"fallback,omitzero"`
|
||||
Footer valuer.UnsetOrNonEmptyString `json:"footer,omitzero"`
|
||||
Fields []ChannelSlackField `json:"fields,omitzero"`
|
||||
Actions []ChannelSlackAction `json:"actions,omitzero"`
|
||||
}
|
||||
|
||||
type ChannelSlackField struct {
|
||||
Title string `json:"title" required:"true" description:"The field title."`
|
||||
Value string `json:"value" required:"true" description:"The field value; an Alertmanager template."`
|
||||
Short *bool `json:"short,omitempty" description:"Render the field side by side with other short fields."`
|
||||
Title string `json:"title" required:"true"`
|
||||
Value string `json:"value" required:"true"`
|
||||
Short *bool `json:"short,omitempty"`
|
||||
}
|
||||
|
||||
// ChannelSlackAction is a link button when URL is set, otherwise a message
|
||||
// button that needs Name. Upstream clears whichever side is not in use.
|
||||
type ChannelSlackAction struct {
|
||||
Type string `json:"type" required:"true" description:"The Slack action type, normally button."`
|
||||
Text string `json:"text" required:"true" description:"The button label."`
|
||||
URL string `json:"url" description:"The link the button opens; required unless name is set."`
|
||||
Style string `json:"style" description:"The button style: default, primary or danger."`
|
||||
Name string `json:"name" description:"The action name Slack posts back for a message button; required unless url is set."`
|
||||
Value string `json:"value" description:"The value Slack posts back with a message button."`
|
||||
Confirm *ChannelSlackConfirmation `json:"confirm,omitempty" description:"A confirmation dialog shown before the action runs."`
|
||||
Type string `json:"type" required:"true"`
|
||||
Text string `json:"text" required:"true"`
|
||||
URL string `json:"url"`
|
||||
Style string `json:"style"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Confirm *ChannelSlackConfirmation `json:"confirm,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelSlackConfirmation struct {
|
||||
Text string `json:"text" required:"true" description:"The dialog body."`
|
||||
Title string `json:"title" description:"The dialog title."`
|
||||
OkText string `json:"okText" description:"The label of the confirm button."`
|
||||
DismissText string `json:"dismissText" description:"The label of the cancel button."`
|
||||
Text string `json:"text" required:"true"`
|
||||
Title string `json:"title"`
|
||||
OkText string `json:"okText"`
|
||||
DismissText string `json:"dismissText"`
|
||||
}
|
||||
|
||||
func (c *ChannelSlackConfig) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -16,11 +16,11 @@ const bearerAuthorizationType = "Bearer"
|
||||
// was really a bearer token. Username or Password may be set without the other,
|
||||
// as upstream allows, but not together with BearerToken.
|
||||
type ChannelWebhookConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to true."`
|
||||
URL string `json:"url" required:"true" format:"password" description:"The endpoint that receives the Alertmanager webhook payload. A secret; returned by create, update and fetch by ID, never by list."`
|
||||
Username string `json:"username" description:"The basic auth user; cannot be combined with bearerToken."`
|
||||
Password string `json:"password" format:"password" description:"The basic auth password; cannot be combined with bearerToken."`
|
||||
BearerToken string `json:"bearerToken" format:"password" description:"The bearer token sent in the Authorization header; cannot be combined with username or password."`
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
URL string `json:"url" required:"true" format:"password"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password" format:"password"`
|
||||
BearerToken string `json:"bearerToken" format:"password"`
|
||||
}
|
||||
|
||||
func (c *ChannelWebhookConfig) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -18,10 +18,10 @@ import (
|
||||
// Name is the immutable DNS1123 identity references will point at; DisplayName is the
|
||||
// free-text label.
|
||||
type PostableNotificationChannel struct {
|
||||
Name string `json:"name" description:"The immutable DNS-1123 identifier of the channel (lowercase letters, digits and hyphens, at most 63 characters). Required unless generateName is true, in which case it must be empty. Alert rules and routing policies reference displayName, not name."`
|
||||
GenerateName bool `json:"generateName" description:"Derive name from displayName plus a random suffix instead of supplying it. Defaults to false."`
|
||||
DisplayName string `json:"displayName" description:"The label shown in the UI. Alert rules and routing policies reference it, not name. Defaults to name; required when generateName is true."`
|
||||
Config ChannelConfig `json:"config" required:"true" description:"The provider kind and its spec."`
|
||||
Name string `json:"name"`
|
||||
GenerateName bool `json:"generateName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Config ChannelConfig `json:"config" required:"true"`
|
||||
}
|
||||
|
||||
func (p *PostableNotificationChannel) UnmarshalJSON(data []byte) error {
|
||||
@@ -93,7 +93,7 @@ func (p *PostableNotificationChannel) validateName() error {
|
||||
// Name is immutable, and DisplayName cannot be edited until the routing
|
||||
// policies and rules referencing it migrate onto Name.
|
||||
type UpdatableNotificationChannel struct {
|
||||
Config ChannelConfig `json:"config" required:"true" description:"The replacement provider kind and spec. Name and displayName are immutable and not part of the body."`
|
||||
Config ChannelConfig `json:"config" required:"true"`
|
||||
}
|
||||
|
||||
func (u *UpdatableNotificationChannel) UnmarshalJSON(data []byte) error {
|
||||
@@ -115,7 +115,7 @@ func (u *UpdatableNotificationChannel) Validate() error {
|
||||
// It carries no name because nothing is persisted: the receiver a test builds
|
||||
// is thrown away once the notification is delivered.
|
||||
type TestableNotificationChannel struct {
|
||||
Config ChannelConfig `json:"config" required:"true" description:"The replacement provider kind and spec. Name and displayName are immutable and not part of the body."`
|
||||
Config ChannelConfig `json:"config" required:"true"`
|
||||
}
|
||||
|
||||
func (t *TestableNotificationChannel) UnmarshalJSON(data []byte) error {
|
||||
@@ -166,12 +166,12 @@ func decodeStrict(data []byte, target any) error {
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type GettableNotificationChannel struct {
|
||||
Name string `json:"name" required:"true" description:"The immutable DNS-1123 identifier of the channel. Alert rules and routing policies reference displayName, not name."`
|
||||
DisplayName string `json:"displayName" required:"true" description:"The label shown in the UI. Alert rules and routing policies reference it, not name."`
|
||||
Config ChannelConfig `json:"config" required:"true" description:"The provider kind and spec, credentials included."`
|
||||
ID valuer.UUID `json:"id" required:"true" description:"The channel UUID."`
|
||||
CreatedAt time.Time `json:"createdAt" required:"true" description:"When the channel was created, as an RFC 3339 timestamp."`
|
||||
UpdatedAt time.Time `json:"updatedAt" required:"true" description:"When the channel was last updated, as an RFC 3339 timestamp."`
|
||||
Name string `json:"name" required:"true"`
|
||||
DisplayName string `json:"displayName" required:"true"`
|
||||
Config ChannelConfig `json:"config" required:"true"`
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
CreatedAt time.Time `json:"createdAt" required:"true"`
|
||||
UpdatedAt time.Time `json:"updatedAt" required:"true"`
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
@@ -179,19 +179,19 @@ type GettableNotificationChannel struct {
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ListedNotificationChannel carries no configuration. The spec, credentials
|
||||
// included, is returned by create, update and fetch by ID, never by list.
|
||||
// included, is only returned by a fetch by ID.
|
||||
type ListedNotificationChannel struct {
|
||||
ID valuer.UUID `json:"id" required:"true" description:"The channel UUID."`
|
||||
Name string `json:"name" required:"true" description:"The immutable DNS-1123 identifier of the channel. Alert rules and routing policies reference displayName, not name."`
|
||||
DisplayName string `json:"displayName" required:"true" description:"The label shown in the UI. Alert rules and routing policies reference it, not name."`
|
||||
Kind ChannelKind `json:"kind" required:"true" description:"The channel provider kind."`
|
||||
CreatedAt time.Time `json:"createdAt" required:"true" description:"When the channel was created, as an RFC 3339 timestamp."`
|
||||
UpdatedAt time.Time `json:"updatedAt" required:"true" description:"When the channel was last updated, as an RFC 3339 timestamp."`
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
DisplayName string `json:"displayName" required:"true"`
|
||||
Kind ChannelKind `json:"kind" required:"true"`
|
||||
CreatedAt time.Time `json:"createdAt" required:"true"`
|
||||
UpdatedAt time.Time `json:"updatedAt" required:"true"`
|
||||
}
|
||||
|
||||
// ListableNotificationChannel is one page of the channel list. Total counts every
|
||||
// channel the filter matches, not just the ones on the page.
|
||||
type ListableNotificationChannel struct {
|
||||
Channels []*ListedNotificationChannel `json:"channels" required:"true" nullable:"false" description:"The channels on this page, without their configuration; fetch a channel by ID for that."`
|
||||
Total int64 `json:"total" required:"true" description:"The number of channels the filter matches across all pages."`
|
||||
Channels []*ListedNotificationChannel `json:"channels" required:"true" nullable:"false"`
|
||||
Total int64 `json:"total" required:"true"`
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
)
|
||||
|
||||
type ChannelConfig struct {
|
||||
Kind ChannelKind `json:"kind" required:"true" description:"The channel provider kind; one of the supported kinds listed in the enum."`
|
||||
Spec any `json:"spec" required:"true" description:"The provider-specific configuration; its shape is selected by kind."`
|
||||
Kind ChannelKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (c *ChannelConfig) UnmarshalJSON(data []byte) error {
|
||||
@@ -71,8 +71,8 @@ func (c ChannelConfig) Validate() error {
|
||||
// ChannelConfigVariant names one branch of the union. Each instantiation becomes
|
||||
// its own OpenAPI component with kind pinned to the one value it accepts.
|
||||
type ChannelConfigVariant[S any] struct {
|
||||
Kind string `json:"kind" required:"true" description:"The channel provider kind this variant accepts."`
|
||||
Spec S `json:"spec" required:"true" description:"The provider-specific configuration for this kind."`
|
||||
Kind string `json:"kind" required:"true"`
|
||||
Spec S `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (v ChannelConfigVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
|
||||
@@ -61,12 +61,12 @@ func (o ChannelListOrder) IsValid() bool {
|
||||
// ListChannelsParams filters and orders the channel list. Query matches the
|
||||
// display name case-insensitively.
|
||||
type ListChannelsParams struct {
|
||||
Query string `query:"query" json:"query" description:"Case-insensitive match on the display name; at most 1024 characters."`
|
||||
Kind ChannelKind `query:"kind" json:"kind" description:"Only channels of this provider kind."`
|
||||
Sort ChannelListSort `query:"sort" json:"sort" description:"The sort column: updated_at (default), created_at or name."`
|
||||
Order ChannelListOrder `query:"order" json:"order" description:"The sort order: asc or desc (default)."`
|
||||
Limit int `query:"limit" json:"limit" description:"The page size; defaults to 20 and is capped at 200."`
|
||||
Offset int `query:"offset" json:"offset" description:"The number of channels to skip; defaults to 0."`
|
||||
Query string `query:"query" json:"query"`
|
||||
Kind ChannelKind `query:"kind" json:"kind"`
|
||||
Sort ChannelListSort `query:"sort" json:"sort"`
|
||||
Order ChannelListOrder `query:"order" json:"order"`
|
||||
Limit int `query:"limit" json:"limit"`
|
||||
Offset int `query:"offset" json:"offset"`
|
||||
}
|
||||
|
||||
func (p *ListChannelsParams) Validate() error {
|
||||
|
||||
@@ -45,7 +45,7 @@ func (ChannelRepairAction) Enum() []any {
|
||||
|
||||
// RepairChannelParams defaults to a dry run; only apply=true writes anything.
|
||||
type RepairChannelParams struct {
|
||||
Apply bool `query:"apply" json:"apply" description:"Apply the diagnosed action; defaults to false, a dry run that only reports it."`
|
||||
Apply bool `query:"apply" json:"apply"`
|
||||
}
|
||||
|
||||
// ChannelRepair is one channel's diagnosis and the action that makes it
|
||||
@@ -53,13 +53,13 @@ type RepairChannelParams struct {
|
||||
// channel itself for none and retype, every part of a split, nothing for a
|
||||
// delete. Blockers explain why an action was not, or would not be, applied.
|
||||
type ChannelRepair struct {
|
||||
ID valuer.UUID `json:"id" required:"true" description:"The UUID of the diagnosed channel."`
|
||||
Defect ChannelDefect `json:"defect" required:"true" description:"Why v2 cannot read the channel; none when it is readable."`
|
||||
Detail string `json:"detail" description:"A human-readable explanation of the defect."`
|
||||
Action ChannelRepairAction `json:"action" required:"true" description:"The repair that makes the channel readable: none, retype, split or delete."`
|
||||
Blockers []string `json:"blockers,omitempty" description:"Why the action was not, or would not be, applied."`
|
||||
Channels []*ListedNotificationChannel `json:"channels" description:"The channels that exist once the action is applied."`
|
||||
Applied bool `json:"applied" required:"true" description:"Whether the action was written; false on a dry run."`
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
Defect ChannelDefect `json:"defect" required:"true"`
|
||||
Detail string `json:"detail"`
|
||||
Action ChannelRepairAction `json:"action" required:"true"`
|
||||
Blockers []string `json:"blockers,omitempty"`
|
||||
Channels []*ListedNotificationChannel `json:"channels"`
|
||||
Applied bool `json:"applied" required:"true"`
|
||||
}
|
||||
|
||||
// Diagnose reads the channel the way v2 does and reports the first defect in
|
||||
|
||||
@@ -29,13 +29,13 @@ type DashboardView struct {
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
|
||||
Name string `bun:"name,type:text,notnull" json:"name" required:"true" description:"The view name."`
|
||||
Data DashboardViewData `bun:"data,type:text,notnull" json:"data" required:"true" description:"The saved list filter and sort."`
|
||||
OrgID valuer.UUID `bun:"org_id,type:text,notnull" json:"orgId" required:"true" description:"The organization that owns the view."`
|
||||
Name string `bun:"name,type:text,notnull" json:"name" required:"true"`
|
||||
Data DashboardViewData `bun:"data,type:text,notnull" json:"data" required:"true"`
|
||||
OrgID valuer.UUID `bun:"org_id,type:text,notnull" json:"orgId" required:"true"`
|
||||
}
|
||||
|
||||
type DashboardViewData struct {
|
||||
Version string `json:"version" required:"true" description:"The view data schema version. Must be v1."`
|
||||
Version string `json:"version" required:"true"`
|
||||
ListFilter
|
||||
}
|
||||
|
||||
@@ -52,8 +52,8 @@ func (d *DashboardViewData) Validate() error {
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type PostableDashboardView struct {
|
||||
Name string `json:"name" required:"true" maxLength:"64" description:"The view name, at most 64 characters."`
|
||||
Data DashboardViewData `json:"data" required:"true" description:"The list filter and sort to save."`
|
||||
Name string `json:"name" required:"true"`
|
||||
Data DashboardViewData `json:"data" required:"true"`
|
||||
}
|
||||
|
||||
func (p *PostableDashboardView) UnmarshalJSON(data []byte) error {
|
||||
@@ -103,7 +103,7 @@ func (v *DashboardView) Update(updateable UpdatableDashboardView) {
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type ListableDashboardView struct {
|
||||
Views []*DashboardView `json:"views" required:"true" nullable:"false" description:"The saved dashboard list views."`
|
||||
Views []*DashboardView `json:"views" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -4,10 +4,10 @@ package dashboardtypes
|
||||
// references a given metric, along with the group-by and filter labels used for
|
||||
// that metric so callers can reason about the impact of dropping its labels.
|
||||
type DashboardPanelRef struct {
|
||||
DashboardID string `json:"dashboardId" required:"true" description:"The id of the dashboard containing the panel."`
|
||||
DashboardName string `json:"dashboardName" required:"true" description:"The dashboard name."`
|
||||
PanelID string `json:"panelId" required:"true" description:"The panel id within the dashboard."`
|
||||
PanelName string `json:"panelName" required:"true" description:"The panel title."`
|
||||
GroupBy []string `json:"groupBy,omitempty" description:"The metric labels the panel groups by."`
|
||||
FilterBy []string `json:"filterBy,omitempty" description:"The metric labels the panel filters on."`
|
||||
DashboardID string `json:"dashboardId" required:"true"`
|
||||
DashboardName string `json:"dashboardName" required:"true"`
|
||||
PanelID string `json:"panelId" required:"true"`
|
||||
PanelName string `json:"panelName" required:"true"`
|
||||
GroupBy []string `json:"groupBy,omitempty"`
|
||||
FilterBy []string `json:"filterBy,omitempty"`
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ func (o ListOrder) IsValid() bool {
|
||||
var ErrCodeDashboardListInvalid = errors.MustNewCode("dashboard_list_invalid")
|
||||
|
||||
type ListFilter struct {
|
||||
Query string `query:"query" json:"query" description:"A filter expression over the reserved keys name, description, created_at, updated_at, created_by and locked, and over tag keys, using the SigNoz filter grammar: comparisons such as =, !=, CONTAINS, LIKE, IN, < and >, single-quoted strings, RFC3339 timestamps in quotes for created_at and updated_at, combined with AND, OR and NOT, for example name CONTAINS 'overview' AND created_at >= '2026-10-21T00:00:00Z'. At most 1024 characters. Empty matches every dashboard."`
|
||||
Sort ListSort `query:"sort" json:"sort" description:"The sort field: updated_at (default), created_at or name."`
|
||||
Order ListOrder `query:"order" json:"order" description:"The sort direction: asc or desc (default)."`
|
||||
Query string `query:"query" json:"query"`
|
||||
Sort ListSort `query:"sort" json:"sort"`
|
||||
Order ListOrder `query:"order" json:"order"`
|
||||
}
|
||||
|
||||
func (f *ListFilter) Validate() error {
|
||||
@@ -75,8 +75,8 @@ func (f *ListFilter) Validate() error {
|
||||
|
||||
type ListDashboardsV2Params struct {
|
||||
ListFilter
|
||||
Limit int `query:"limit" description:"The page size. Defaults to 20 and is capped at 200."`
|
||||
Offset int `query:"offset" description:"The number of dashboards to skip. Defaults to 0."`
|
||||
Limit int `query:"limit"`
|
||||
Offset int `query:"offset"`
|
||||
}
|
||||
|
||||
func (p *ListDashboardsV2Params) Validate() error {
|
||||
@@ -114,22 +114,22 @@ type listedDashboardV2 struct {
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
|
||||
OrgID valuer.UUID `json:"orgId" required:"true" description:"The organization that owns the dashboard."`
|
||||
Locked bool `json:"locked" required:"true" description:"Whether the dashboard is locked against update and patch."`
|
||||
Source Source `json:"source" required:"true" description:"Where the dashboard came from: user or integration. System dashboards are not listed."`
|
||||
SchemaVersion string `json:"schemaVersion" required:"true" description:"The dashboard schema version, v6 for v2 dashboards."`
|
||||
Name string `json:"name" required:"true" description:"The dashboard's unique, immutable name."`
|
||||
Image string `json:"image,omitempty" description:"Icon or logo shown in the dashboard list: the path of an icon or logo bundled with the UI under /assets/Icons/ or /assets/Logos/, or a base64 image data URI. Omitted when none is set."`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false" description:"The tags attached to the dashboard."`
|
||||
Spec listedDashboardV2Spec `json:"spec" required:"true" description:"The display part of the dashboard spec. Panels, variables and layouts are not included in list rows."`
|
||||
OrgID valuer.UUID `json:"orgId" required:"true"`
|
||||
Locked bool `json:"locked" required:"true"`
|
||||
Source Source `json:"source" required:"true"`
|
||||
SchemaVersion string `json:"schemaVersion" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
|
||||
Spec listedDashboardV2Spec `json:"spec" required:"true"`
|
||||
// Legacy marks a dashboard whose stored data is not yet in the v2 (perses)
|
||||
// schema. Such rows are extracted best-effort from the v1 shape so the list
|
||||
// still surfaces them; callers should route them to the legacy view.
|
||||
Legacy bool `json:"legacy" required:"true" description:"Whether the dashboard is still in the v1 schema. It is listed but cannot be fetched or edited through the v2 endpoints."`
|
||||
Legacy bool `json:"legacy" required:"true"`
|
||||
}
|
||||
|
||||
type listedDashboardV2Spec struct {
|
||||
Display Display `json:"display,omitempty" description:"The dashboard name and description."`
|
||||
Display Display `json:"display,omitempty"`
|
||||
}
|
||||
|
||||
func newListedDashboardV2(v2 *DashboardV2) *listedDashboardV2 {
|
||||
@@ -184,10 +184,10 @@ func newLegacyListedDashboardV2(storable *StorableDashboard, tags []*tagtypes.Ta
|
||||
}
|
||||
|
||||
type ListableDashboardV2 struct {
|
||||
Dashboards []*listedDashboardV2 `json:"dashboards" required:"true" nullable:"false" description:"The dashboards on this page."`
|
||||
Total int64 `json:"total" required:"true" description:"The number of dashboards matching the filter across every page."`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false" description:"Every dashboard tag in the organization, for building filters."`
|
||||
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false" description:"The filter keys that address dashboard columns rather than tags."`
|
||||
Dashboards []*listedDashboardV2 `json:"dashboards" required:"true" nullable:"false"`
|
||||
Total int64 `json:"total" required:"true"`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
|
||||
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) *ListableDashboardV2 {
|
||||
@@ -207,14 +207,14 @@ func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsBy
|
||||
// state. Only the per-user list endpoint emits this; the pure list omits pins.
|
||||
type listedDashboardForUserV2 struct {
|
||||
listedDashboardV2
|
||||
Pinned bool `json:"pinned" required:"true" description:"Whether the calling user has pinned the dashboard."`
|
||||
Pinned bool `json:"pinned" required:"true"`
|
||||
}
|
||||
|
||||
type ListableDashboardForUserV2 struct {
|
||||
Dashboards []*listedDashboardForUserV2 `json:"dashboards" required:"true" nullable:"false" description:"The dashboards on this page, each with the calling user's pin state."`
|
||||
Total int64 `json:"total" required:"true" description:"The number of dashboards matching the filter across every page."`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false" description:"Every dashboard tag in the organization, for building filters."`
|
||||
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false" description:"The filter keys that address dashboard columns rather than tags."`
|
||||
Dashboards []*listedDashboardForUserV2 `json:"dashboards" required:"true" nullable:"false"`
|
||||
Total int64 `json:"total" required:"true"`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
|
||||
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
// StorableDashboardWithPinInfo is the per-row shape Store.ListForUser returns: the dashboard
|
||||
|
||||
@@ -68,14 +68,14 @@ type DashboardV2 struct {
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
|
||||
OrgID valuer.UUID `json:"orgId" required:"true" description:"The organization that owns the dashboard."`
|
||||
Locked bool `json:"locked" required:"true" description:"Whether the dashboard is locked. A locked dashboard rejects update and patch until it is unlocked."`
|
||||
Source Source `json:"source" required:"true" description:"Where the dashboard came from: user, integration or system. Only user dashboards can be modified."`
|
||||
OrgID valuer.UUID `json:"orgId" required:"true"`
|
||||
Locked bool `json:"locked" required:"true"`
|
||||
Source Source `json:"source" required:"true"`
|
||||
|
||||
DashboardV2MetadataBase
|
||||
Name string `json:"name" required:"true" description:"The dashboard's unique, immutable name, a DNS-1123 label."`
|
||||
Tags []*tagtypes.Tag `json:"tags" required:"true" description:"The key/value tags attached to the dashboard, for example key team with value payments."`
|
||||
Spec DashboardSpec `json:"spec" required:"true" description:"The dashboard content: display, variables, panels, layouts and links."`
|
||||
Name string `json:"name" required:"true"`
|
||||
Tags []*tagtypes.Tag `json:"tags" required:"true"`
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (d *DashboardV2) ErrIfNotMutable() error {
|
||||
@@ -198,8 +198,8 @@ func nextCloneDisplayName(name string) string {
|
||||
}
|
||||
|
||||
type DashboardV2MetadataBase struct {
|
||||
SchemaVersion string `json:"schemaVersion" required:"true" pattern:"^v6$" description:"The dashboard schema version. Must be v6."`
|
||||
Image string `json:"image" description:"Icon or logo shown in the dashboard list: the path of an icon or logo bundled with the UI under /assets/Icons/ or /assets/Logos/ (only the prefix is validated), or a base64 image data URI for png, jpeg, gif, webp, avif or svg. Empty for none."`
|
||||
SchemaVersion string `json:"schemaVersion" required:"true"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
func (m DashboardV2MetadataBase) validateImage() error {
|
||||
@@ -222,10 +222,10 @@ func (m DashboardV2MetadataBase) validateImage() error {
|
||||
|
||||
type PostableDashboardV2 struct {
|
||||
DashboardV2MetadataBase
|
||||
Name string `json:"name" description:"The dashboard's unique name, a DNS-1123 label (lowercase letters, digits and hyphens, at most 63 characters). Immutable after creation. Must be empty when generateName is true."`
|
||||
GenerateName bool `json:"generateName" description:"When true the server derives name from spec.display.name plus a random suffix. Requires an empty name and a non-empty spec.display.name."`
|
||||
Tags []tagtypes.PostableTag `json:"tags" required:"true" maxItems:"10" description:"Up to 10 key/value tags, for example key team with value payments. Keys must not be one of the reserved list filter keys: name, description, created_at, updated_at, created_by, locked, source. Use [] for none."`
|
||||
Spec DashboardSpec `json:"spec" required:"true" description:"The dashboard content: display, variables, panels, layouts and links."`
|
||||
Name string `json:"name"`
|
||||
GenerateName bool `json:"generateName"`
|
||||
Tags []tagtypes.PostableTag `json:"tags" required:"true"`
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy string, source Source) (*DashboardV2, error) {
|
||||
@@ -371,14 +371,14 @@ type GettableDashboardV2 struct {
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
|
||||
OrgID valuer.UUID `json:"orgId" required:"true" description:"The organization that owns the dashboard."`
|
||||
Locked bool `json:"locked" required:"true" description:"Whether the dashboard is locked. A locked dashboard rejects update and patch until it is unlocked."`
|
||||
Source Source `json:"source" required:"true" description:"Where the dashboard came from: user, integration or system. Only user dashboards can be modified."`
|
||||
OrgID valuer.UUID `json:"orgId" required:"true"`
|
||||
Locked bool `json:"locked" required:"true"`
|
||||
Source Source `json:"source" required:"true"`
|
||||
|
||||
DashboardV2MetadataBase
|
||||
Name string `json:"name" required:"true" description:"The dashboard's unique, immutable name, a DNS-1123 label."`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true" description:"The key/value tags attached to the dashboard, for example key team with value payments."`
|
||||
Spec DashboardSpec `json:"spec" required:"true" description:"The dashboard content: display, variables, panels, layouts and links."`
|
||||
Name string `json:"name" required:"true"`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true"`
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (d DashboardV2) ToGettableDashboardV2() GettableDashboardV2 {
|
||||
@@ -402,14 +402,14 @@ type GettableSystemDashboard struct {
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
|
||||
OrgID valuer.UUID `json:"orgId" required:"true" description:"The organization that owns the dashboard."`
|
||||
Locked bool `json:"locked" required:"true" description:"Whether the dashboard is locked. A locked dashboard rejects update and patch until it is unlocked."`
|
||||
Source Source `json:"source" required:"true" description:"Where the dashboard came from: user, integration or system. Only user dashboards can be modified."`
|
||||
OrgID valuer.UUID `json:"orgId" required:"true"`
|
||||
Locked bool `json:"locked" required:"true"`
|
||||
Source Source `json:"source" required:"true"`
|
||||
|
||||
DashboardV2MetadataBase
|
||||
Name string `json:"name" required:"true" description:"The dashboard's unique, immutable name, a DNS-1123 label."`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true" description:"The key/value tags attached to the dashboard, for example key team with value payments."`
|
||||
Spec DashboardSpec `json:"spec" required:"true" description:"The dashboard content: display, variables, panels, layouts and links."`
|
||||
Name string `json:"name" required:"true"`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true"`
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (d DashboardV2) ToGettableSystemDashboard() GettableSystemDashboard {
|
||||
@@ -456,9 +456,9 @@ type StorableDashboardV2Metadata = DashboardV2MetadataBase
|
||||
|
||||
type UpdatableDashboardV2 struct {
|
||||
DashboardV2MetadataBase
|
||||
Name string `json:"name" required:"true" description:"The dashboard's current name. The name is immutable, so a different value is rejected."`
|
||||
Tags []tagtypes.PostableTag `json:"tags" required:"true" maxItems:"10" description:"The full replacement tag set, up to 10 key/value tags, for example key team with value payments. Keys must not be one of the reserved list filter keys: name, description, created_at, updated_at, created_by, locked, source."`
|
||||
Spec DashboardSpec `json:"spec" required:"true" description:"The full replacement dashboard content: display, variables, panels, layouts and links."`
|
||||
Name string `json:"name" required:"true"`
|
||||
Tags []tagtypes.PostableTag `json:"tags" required:"true"`
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (u *UpdatableDashboardV2) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -21,13 +21,13 @@ import (
|
||||
// dropped: SigNoz never reads it (queries carry their own signal/source), so
|
||||
// the drift test allowlists it as an intentional omission.
|
||||
type DashboardSpec struct {
|
||||
Display Display `json:"display" required:"true" description:"The dashboard title and description."`
|
||||
Variables []Variable `json:"variables" required:"true" nullable:"false" description:"The dashboard variables, each a ListVariable or TextVariable envelope, referenced in queries as $name. Names must be unique. Use [] for none."`
|
||||
Panels map[string]*Panel `json:"panels" required:"true" nullable:"false" description:"The panels keyed by panel id. An id has up to 75 characters from letters, digits, dot, underscore and hyphen. A panel is shown only where a layout item references it as #/spec/panels/<id>. Use {} for none."`
|
||||
Layouts []Layout `json:"layouts" required:"true" nullable:"false" description:"The Grid layouts that place panels, at most 500. Each panel can be placed at most once. Use [] for none."`
|
||||
Duration common.DurationString `json:"duration" description:"The default time range, as a Prometheus duration such as 1h or 7d. Stored, but not applied by the UI."`
|
||||
RefreshInterval common.DurationString `json:"refreshInterval" description:"The auto refresh interval, as a Prometheus duration such as 30s. Stored, but not applied by the UI."`
|
||||
Links []Link `json:"links,omitzero" description:"External links stored with the dashboard. Not shown by the UI."`
|
||||
Display Display `json:"display" required:"true"`
|
||||
Variables []Variable `json:"variables" required:"true" nullable:"false"`
|
||||
Panels map[string]*Panel `json:"panels" required:"true" nullable:"false"`
|
||||
Layouts []Layout `json:"layouts" required:"true" nullable:"false"`
|
||||
Duration common.DurationString `json:"duration"`
|
||||
RefreshInterval common.DurationString `json:"refreshInterval"`
|
||||
Links []Link `json:"links,omitzero"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -123,22 +123,10 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// validatePanelQueryCount left exactly one query for a spec that reads it.
|
||||
if spec, ok := panel.Spec.Plugin.Spec.(queryBoundPanelSpec); ok {
|
||||
if err := spec.validateAgainstQuery(panel.Spec.Queries[0], path+".spec.plugin.spec"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryBoundPanelSpec is a panel spec with fields that depend on the panel's
|
||||
// query. It is checked after the query itself validates.
|
||||
type queryBoundPanelSpec interface {
|
||||
validateAgainstQuery(query Query, path string) error
|
||||
}
|
||||
|
||||
func validatePanelQueryCount(queries []Query, panelKind PanelPluginKind, path string) error {
|
||||
if queries == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: is required and must not be null; use [] for a panel that renders without a query", path)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// PatchableDashboardV2 is an RFC 6902 patch request.
|
||||
type PatchableDashboardV2 struct {
|
||||
// Ops shapes the OpenAPI schema; tagged so swaggest reflects it.
|
||||
Ops []JSONPatchOperation `json:"ops" description:"The RFC 6902 operations, applied in order against the dashboard's postable shape."`
|
||||
Ops []JSONPatchOperation `json:"ops"`
|
||||
// patch holds the decoded payload, set by UnmarshalJSON.
|
||||
patch jsonpatch.Patch
|
||||
}
|
||||
@@ -29,7 +29,7 @@ func (PatchableDashboardV2) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
}
|
||||
|
||||
type JSONPatchOperation struct {
|
||||
Op PatchOp `json:"op" required:"true" description:"The RFC 6902 verb: add, remove, replace, move, copy or test. remove on a missing path is a no-op and add creates missing parents."`
|
||||
Op PatchOp `json:"op" required:"true"`
|
||||
Path string `json:"path" required:"true" description:"JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0, /tags/-."`
|
||||
// `value` is required for add/replace/test.
|
||||
Value any `json:"value" description:"Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable; /spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /spec/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy."`
|
||||
|
||||
@@ -1870,326 +1870,6 @@ func TestInvalidateAreaChartPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScatterPlotPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/ScatterPlotPanel",
|
||||
"spec": {}
|
||||
},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &ScatterPlotPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
|
||||
|
||||
assert.Empty(t, spec.Dimensions.X, "an omitted x stays empty so the renderer picks the first value column")
|
||||
assert.Empty(t, spec.Dimensions.Y, "an omitted y stays empty so the renderer picks the second value column")
|
||||
assert.Empty(t, spec.Dimensions.ColorBy, "an omitted colorBy stays empty so the renderer colours by every group-by label")
|
||||
assert.Equal(t, "auto", spec.Axes.X.Scale.ValueOrDefault(), "expected x scale default auto")
|
||||
assert.Equal(t, "auto", spec.Axes.Y.Scale.ValueOrDefault(), "expected y scale default auto")
|
||||
assert.Nil(t, spec.Axes.X.SoftMin, "expected x softMin unset")
|
||||
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
|
||||
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
|
||||
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
|
||||
|
||||
output, err := json.Marshal(d)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
assert.Contains(t, string(output), `"axes":{"x":{"softMin":null,"softMax":null,"scale":"auto","label":""},"y":{"softMin":null,"softMax":null,"scale":"auto","label":""}}`, "expected default axes in stored/response JSON")
|
||||
assert.Contains(t, string(output), `"dimensions":{"x":"","y":"","sizeBy":"","colorBy":null}`, "expected empty dimensions in stored/response JSON")
|
||||
assert.Contains(t, string(output), `"chartAppearance":{"points":{"size":null,"minSize":null,"maxSize":null,"opacity":null}}`, "omitted point settings stay null so the renderer applies its defaults")
|
||||
}
|
||||
|
||||
func TestValidateScatterPlotDashboard(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/perses_scatter_plot_panel.json")
|
||||
require.NoError(t, err, "reading example file")
|
||||
|
||||
spec, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &ScatterPlotPanelSpec{}, spec.Panels["p1"].Spec.Plugin.Spec)
|
||||
panelSpec := spec.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
|
||||
softMin, softMax := 0.0, 1000.0
|
||||
size, minSize, maxSize, opacity := PointDiameter(8), PointDiameter(4), PointDiameter(24), PointOpacity(0.1)
|
||||
assert.Equal(t, &ScatterPlotPanelSpec{
|
||||
Visualization: BasicVisualization{TimePreference: TimePreferenceLast1Hr},
|
||||
Dimensions: ScatterPlotDimensions{X: "A.count()", Y: "A.p99(duration_nano)", SizeBy: "A.countIf(has_error = true)", ColorBy: []string{"k8s.namespace.name", "k8s.pod.name"}},
|
||||
Formatting: TableFormatting{ColumnUnits: map[string]string{"A.p99(duration_nano)": "ns"}, DecimalPrecision: PrecisionOption1},
|
||||
Axes: ScatterPlotAxes{
|
||||
X: ScatterPlotAxis{SoftMin: &softMin, Scale: AxisScaleLog, Label: "Throughput"},
|
||||
Y: ScatterPlotAxis{SoftMax: &softMax, Scale: AxisScaleSymlog, Label: "p99 latency"},
|
||||
},
|
||||
ChartAppearance: ScatterPlotChartAppearance{Points: ScatterPlotPoints{Size: &size, MinSize: &minSize, MaxSize: &maxSize, Opacity: &opacity}},
|
||||
Legend: Legend{Position: LegendPositionRight, Mode: LegendModeList, CustomColors: map[string]string{"checkout": "#1f77b4"}},
|
||||
Thresholds: []ScatterPlotThreshold{
|
||||
{ThresholdWithLabel: ThresholdWithLabel{Value: 300, Unit: "ms", Color: "#f00", Label: "p99 SLO"}, Axis: ThresholdAxisY},
|
||||
{ThresholdWithLabel: ThresholdWithLabel{Value: 50, Color: "#0f0"}, Axis: ThresholdAxisX},
|
||||
},
|
||||
}, panelSpec)
|
||||
|
||||
dashboard := &DashboardV2{Spec: *spec}
|
||||
request, err := dashboard.GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the panel's query failed")
|
||||
assert.Equal(t, qb.RequestTypeScalar, request.RequestType)
|
||||
require.NotNil(t, request.FormatOptions)
|
||||
assert.True(t, request.FormatOptions.FormatTableResultForUI, "scatter rows are joined the way a table's are")
|
||||
|
||||
require.Len(t, request.CompositeQuery.Queries, 1)
|
||||
query, ok := request.CompositeQuery.Queries[0].Spec.(qb.QueryBuilderQuery[qb.TraceAggregation])
|
||||
require.True(t, ok, "expected a traces builder query")
|
||||
assert.Len(t, query.Aggregations, 3)
|
||||
assert.Len(t, query.GroupBy, 2)
|
||||
|
||||
require.NoError(t, request.Validate(), "the request built from the panel is not a valid scalar request")
|
||||
|
||||
// the panel read back out of storage draws the same scatter plot
|
||||
stored, err := json.Marshal(spec)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
reread, err := unmarshalDashboard(stored)
|
||||
require.NoError(t, err, "the stored dashboard does not validate")
|
||||
assert.Equal(t, panelSpec, reread.Panels["p1"].Spec.Plugin.Spec)
|
||||
rereadRequest, err := (&DashboardV2{Spec: *reread}).GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the stored panel's query failed")
|
||||
assert.Equal(t, request, rereadRequest)
|
||||
}
|
||||
|
||||
func TestInvalidateScatterPlotPanelSpecValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
panelKind string
|
||||
panelSpec string
|
||||
expectedErrorSubstring string
|
||||
}{
|
||||
{
|
||||
scenario: "unknown axis scale",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"x": {"scale": "sqrt"}}}`,
|
||||
expectedErrorSubstring: "invalid axis scale \"sqrt\": must be `auto`, `linear`, `log`, or `symlog`",
|
||||
},
|
||||
{
|
||||
scenario: "non-string axis scale",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"y": {"scale": true}}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal bool",
|
||||
},
|
||||
{
|
||||
scenario: "isLogScale on a scatter plot axis",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"x": {"isLogScale": true}}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "single-axis axes shape on a scatter plot panel",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"softMin": 0}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "unknown threshold axis",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"thresholds": [{"value": 1, "color": "#f00", "axis": "z"}]}`,
|
||||
expectedErrorSubstring: "invalid threshold axis \"z\": must be `x` or `y`",
|
||||
},
|
||||
{
|
||||
scenario: "unknown dimension",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"dimensions": {"z": "A"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "point size below the minimum",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"size": 1}}}`,
|
||||
expectedErrorSubstring: "invalid point diameter 1: must be a whole number between 2 and 40",
|
||||
},
|
||||
{
|
||||
scenario: "max point size above the maximum",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"maxSize": 41}}}`,
|
||||
expectedErrorSubstring: "invalid point diameter 41",
|
||||
},
|
||||
{
|
||||
scenario: "fractional point size",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"minSize": 4.5}}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal number 4.5",
|
||||
},
|
||||
{
|
||||
scenario: "min point size above max",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"minSize": 20, "maxSize": 10}}}`,
|
||||
expectedErrorSubstring: "minSize 20 must not exceed maxSize 10",
|
||||
},
|
||||
{
|
||||
scenario: "point opacity below the floor",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"opacity": 0.05}}}`,
|
||||
expectedErrorSubstring: "invalid point opacity 0.05: must be between 0.1 and 1",
|
||||
},
|
||||
{
|
||||
scenario: "point opacity as a percentage",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"opacity": 70}}}`,
|
||||
expectedErrorSubstring: "invalid point opacity 70",
|
||||
},
|
||||
{
|
||||
scenario: "area fill fields on a scatter plot",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"fillOpacity": 0.5}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "a single colour key as a string",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"dimensions": {"colorBy": "service.name"}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal string",
|
||||
},
|
||||
{
|
||||
scenario: "panel-wide unit on a scatter plot panel",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"formatting": {"unit": "ms"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "threshold without a color",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"thresholds": [{"value": 300}]}`,
|
||||
expectedErrorSubstring: "Color' Error:Field validation for 'Color' failed on the 'required' tag",
|
||||
},
|
||||
{
|
||||
scenario: "scale on a time series panel",
|
||||
panelKind: "signoz/TimeSeriesPanel",
|
||||
panelSpec: `{"axes": {"scale": "log"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "` + test.panelKind + `", "spec": ` + test.panelSpec + `},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected the spec to be rejected")
|
||||
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScatterPlotDimensionsBindToQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
queryPlugin string
|
||||
dimensions string
|
||||
expectedErrorSubstring string
|
||||
}{
|
||||
{
|
||||
scenario: "a multi-aggregation query keys each value column by name and expression",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}, {"expression": "p99(duration_nano)"}]}}`,
|
||||
dimensions: `{"x": "A"}`,
|
||||
expectedErrorSubstring: "dimensions.x: \"A\" is not a value column of the panel's query; value columns: `A.count()`, `A.p99(duration_nano)`",
|
||||
},
|
||||
{
|
||||
scenario: "a colour key the query does not group by",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}}`,
|
||||
dimensions: `{"colorBy": ["service.name", "k8s.pod.name"]}`,
|
||||
expectedErrorSubstring: "dimensions.colorBy: \"k8s.pod.name\" is not a group-by key of the panel's query; group-by keys: `service.name`",
|
||||
},
|
||||
{
|
||||
scenario: "a formula is a value column and carries the labels of the disabled queries it reads",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": true, "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": true, "aggregations": [{"expression": "countIf(has_error = true)"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "B / A"}}
|
||||
]}}`,
|
||||
dimensions: `{"x": "F1", "colorBy": ["service.name"]}`,
|
||||
},
|
||||
{
|
||||
scenario: "clickhouse columns are only known at run time, so the bindings go unchecked",
|
||||
queryPlugin: `{"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}`,
|
||||
dimensions: `{"x": "throughput", "colorBy": ["pod"]}`,
|
||||
},
|
||||
{
|
||||
scenario: "queries grouped by different keys have no rows that join into dots",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "aggregations": [{"expression": "p99(duration_nano)"}], "groupBy": [{"name": "k8s.pod.name"}]}}
|
||||
]}}`,
|
||||
dimensions: `{"x": "A", "y": "B"}`,
|
||||
expectedErrorSubstring: "queries must group by the same keys so their rows join into dots; \"A\" groups by [`service.name`], \"B\" groups by [`k8s.pod.name`]",
|
||||
},
|
||||
{
|
||||
scenario: "group-by order does not matter, since rows join on the label set",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}, {"name": "k8s.pod.name"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "aggregations": [{"expression": "p99(duration_nano)"}], "groupBy": [{"name": "k8s.pod.name"}, {"name": "service.name"}]}}
|
||||
]}}`,
|
||||
dimensions: `{"x": "A", "y": "B", "colorBy": ["service.name"]}`,
|
||||
},
|
||||
{
|
||||
scenario: "every query counts toward the shared group-by, even one disabled and unread",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "aggregations": [{"expression": "p99(duration_nano)"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "C", "signal": "traces", "disabled": true, "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "k8s.pod.name"}]}}
|
||||
]}}`,
|
||||
dimensions: `{"x": "A", "y": "B"}`,
|
||||
expectedErrorSubstring: "\"A\" groups by [`service.name`], \"C\" groups by [`k8s.pod.name`]",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/ScatterPlotPanel", "spec": {"dimensions": ` + test.dimensions + `}},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": ` + test.queryPlugin + `}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
if test.expectedErrorSubstring == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err, "expected the binding to be rejected")
|
||||
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
@@ -2548,7 +2228,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
switch panelKind {
|
||||
case "signoz/ListPanel":
|
||||
return "raw"
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel", "signoz/ScatterPlotPanel":
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel":
|
||||
return "scalar"
|
||||
default:
|
||||
return "time_series"
|
||||
@@ -2592,17 +2272,14 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
{"List+Builder", mkQuery("signoz/ListPanel", "signoz/BuilderQuery", `{"name":"A","signal":"logs"}`), false},
|
||||
{"TimeSeries+AIBuilder", mkQuery("signoz/TimeSeriesPanel", "signoz/AIBuilderQuery", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
|
||||
{"List+AIBuilder", mkQuery("signoz/ListPanel", "signoz/AIBuilderQuery", `{"name":"A"}`), false},
|
||||
{"ScatterPlot+ClickHouse", mkQuery("signoz/ScatterPlotPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
// Top-level: rejected
|
||||
{"Table+PromQL", mkQuery("signoz/TablePanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"ScatterPlot+PromQL", mkQuery("signoz/ScatterPlotPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"List+ClickHouse", mkQuery("signoz/ListPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), true},
|
||||
{"List+PromQL", mkQuery("signoz/ListPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"List+Composite", mkQuery("signoz/ListPanel", "signoz/CompositeQuery", `{"queries":[]}`), true},
|
||||
{"List+Formula", mkQuery("signoz/ListPanel", "signoz/Formula", `{"name":"F1","expression":"A+B"}`), true},
|
||||
// Composite sub-queries
|
||||
{"Table+Composite(promql)", mkComposite("signoz/TablePanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"ScatterPlot+Composite(promql)", mkComposite("signoz/ScatterPlotPanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
{"Table+Composite(builder_ai)", mkComposite("signoz/TablePanel", "builder_ai_query", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type PanelPlugin struct {
|
||||
Kind PanelPluginKind `json:"kind" required:"true" description:"The panel kind. It decides the shape of spec and which query kinds the panel accepts."`
|
||||
Spec any `json:"spec" required:"true" description:"The options for this panel kind. {} accepts every default."`
|
||||
Kind PanelPluginKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
// PrepareJSONSchema marks the envelope with x-signoz-discriminator;
|
||||
@@ -28,17 +28,16 @@ type PanelPlugin struct {
|
||||
// (and strips the duplicate parent properties) after reflection.
|
||||
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,13 +71,12 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
|
||||
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
|
||||
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
|
||||
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
|
||||
}
|
||||
}
|
||||
|
||||
type PanelPluginVariant[S any] struct {
|
||||
Kind string `json:"kind" required:"true" description:"The panel kind."`
|
||||
Spec S `json:"spec" required:"true" description:"The options for this panel kind. {} accepts every default."`
|
||||
Kind string `json:"kind" required:"true"`
|
||||
Spec S `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (v PanelPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
@@ -90,8 +88,8 @@ func (v PanelPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type QueryPlugin struct {
|
||||
Kind QueryPluginKind `json:"kind" required:"true" description:"The query kind. It decides the shape of spec."`
|
||||
Spec any `json:"spec" required:"true" description:"The kind-specific query spec. A builder query's shape depends on its signal."`
|
||||
Kind QueryPluginKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (QueryPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
@@ -170,8 +168,8 @@ func wrapEnvelope(queryType qb.QueryType, spec any) qb.CompositeQuery {
|
||||
}
|
||||
|
||||
type QueryPluginVariant[S any] struct {
|
||||
Kind string `json:"kind" required:"true" description:"The query kind."`
|
||||
Spec S `json:"spec" required:"true" description:"The kind-specific query spec."`
|
||||
Kind string `json:"kind" required:"true"`
|
||||
Spec S `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (v QueryPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
@@ -183,8 +181,8 @@ func (v QueryPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type VariablePlugin struct {
|
||||
Kind VariablePluginKind `json:"kind" required:"true" description:"The value source kind. It decides the shape of spec."`
|
||||
Spec any `json:"spec" required:"true" description:"The kind-specific value source spec."`
|
||||
Kind VariablePluginKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (VariablePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
@@ -222,8 +220,8 @@ func (VariablePlugin) JSONSchemaOneOf() []any {
|
||||
}
|
||||
|
||||
type VariablePluginVariant[S any] struct {
|
||||
Kind string `json:"kind" required:"true" description:"The value source kind."`
|
||||
Spec S `json:"spec" required:"true" description:"The kind-specific value source spec."`
|
||||
Kind string `json:"kind" required:"true"`
|
||||
Spec S `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
@@ -236,17 +234,16 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
|
||||
|
||||
var (
|
||||
panelPluginSpecs = map[PanelPluginKind]func() any{
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -263,17 +260,16 @@ var (
|
||||
VariableKindCustom: func() any { return new(CustomVariableSpec) },
|
||||
}
|
||||
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -12,14 +12,14 @@ import (
|
||||
|
||||
// PublicWidgetQueryRangeParams are the query params of the public panel query-range endpoint.
|
||||
type PublicWidgetQueryRangeParams struct {
|
||||
StartTime string `query:"startTime" required:"false" description:"Start of the time range as a Unix timestamp in milliseconds. Honored only when the public dashboard has timeRangeEnabled."`
|
||||
EndTime string `query:"endTime" required:"false" description:"End of the time range as a Unix timestamp in milliseconds. Honored only when the public dashboard has timeRangeEnabled."`
|
||||
StartTime string `query:"startTime" required:"false"`
|
||||
EndTime string `query:"endTime" required:"false"`
|
||||
}
|
||||
|
||||
// GettablePublicDashboardDataV2 is the anonymous-facing payload of a v2 dashboard.
|
||||
type GettablePublicDashboardDataV2 struct {
|
||||
Dashboard *GettableDashboardV2 `json:"dashboard" description:"The dashboard with panel and variable queries redacted."`
|
||||
PublicDashboard *GettablePublicDasbhboard `json:"publicDashboard" description:"The sharing settings."`
|
||||
Dashboard *GettableDashboardV2 `json:"dashboard"`
|
||||
PublicDashboard *GettablePublicDasbhboard `json:"publicDashboard"`
|
||||
}
|
||||
|
||||
// NewPublicDashboardDataFromDashboardV2 builds the anonymous v2 payload: panel queries
|
||||
@@ -221,7 +221,7 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
|
||||
CompositeQuery: composite,
|
||||
FormatOptions: &qb.FormatOptions{
|
||||
FillGaps: fillGaps,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable || panel.Spec.Plugin.Kind == PanelKindScatterPlot,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -251,38 +251,34 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
|
||||
assert.Equal(t, telemetrytypes.SignalTraces, spec.Signal)
|
||||
})
|
||||
|
||||
t.Run("sets FormatTableResultForUI for panels that join scalar rows", func(t *testing.T) {
|
||||
for _, kind := range []PanelPluginKind{PanelKindTable, PanelKindScatterPlot} {
|
||||
t.Run(string(kind), func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: kind},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
},
|
||||
},
|
||||
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTable},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
})
|
||||
|
||||
t.Run("sets FillGaps from the panel visualization", func(t *testing.T) {
|
||||
|
||||
@@ -25,10 +25,10 @@ const MaxDisplayNameLen = 128
|
||||
const MaxLayoutTitleLen = 256
|
||||
|
||||
type Display struct {
|
||||
Name string `json:"name" required:"true" maxLength:"128" description:"The human-readable title, at most 128 characters."`
|
||||
Name string `json:"name" required:"true"`
|
||||
// Description always serializes ("" included) so a create -> GET round-trip
|
||||
// preserves what a typed client sent; omitempty would drop an explicit "".
|
||||
Description string `json:"description" description:"A free-text description."`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (d Display) Validate(label, path string) error {
|
||||
@@ -43,8 +43,8 @@ func (d Display) Validate(label, path string) error {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type Panel struct {
|
||||
Kind PanelKind `json:"kind" required:"true" description:"The panel envelope kind. Always Panel."`
|
||||
Spec PanelSpec `json:"spec" required:"true" description:"The panel content: display, plugin, queries and links."`
|
||||
Kind PanelKind `json:"kind" required:"true"`
|
||||
Spec PanelSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
// PanelKind is the panel envelope discriminator. Perses leaves it a free
|
||||
@@ -69,10 +69,10 @@ func (k *PanelKind) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type PanelSpec struct {
|
||||
Display Display `json:"display" required:"true" description:"The panel title and description."`
|
||||
Plugin PanelPlugin `json:"plugin" required:"true" description:"The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel or signoz/HeatmapPanel, and spec holds that kind's options ({} accepts every default)."`
|
||||
Queries []Query `json:"queries" required:"true" nullable:"false" description:"The queries the panel runs. Use exactly one entry for a data panel and [] for signoz/TextPanel. To run several named queries or formulas, use one entry whose spec.plugin.kind is signoz/CompositeQuery and put the constituent queries in its plugin spec."`
|
||||
Links []Link `json:"links,omitzero" description:"Context links offered in the data point drilldown menu of the panel, with dashboard variables resolved in the URL."`
|
||||
Display Display `json:"display" required:"true"`
|
||||
Plugin PanelPlugin `json:"plugin" required:"true"`
|
||||
Queries []Query `json:"queries" required:"true" nullable:"false"`
|
||||
Links []Link `json:"links,omitzero"`
|
||||
}
|
||||
|
||||
// Link replicates dashboard.Link (Perses) so its zero-valued fields survive the
|
||||
@@ -80,11 +80,11 @@ type PanelSpec struct {
|
||||
// omitempty, which drops "" and false; here every field always serializes so a
|
||||
// typed client reads back exactly what it sent.
|
||||
type Link struct {
|
||||
Name string `json:"name" description:"The link label."`
|
||||
URL string `json:"url" description:"The link target. May reference dashboard variables as {{name}} when renderVariables is true."`
|
||||
Tooltip string `json:"tooltip" description:"Hover text for the link. Stored, but not shown by the UI."`
|
||||
RenderVariables bool `json:"renderVariables" description:"Whether dashboard variable values are substituted into url before opening it."`
|
||||
TargetBlank bool `json:"targetBlank" description:"Whether the link opens in a new tab."`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Tooltip string `json:"tooltip"`
|
||||
RenderVariables bool `json:"renderVariables"`
|
||||
TargetBlank bool `json:"targetBlank"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -92,13 +92,13 @@ type Link struct {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type Query struct {
|
||||
Kind qb.RequestType `json:"kind" required:"true" description:"The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, trace for trace panels."`
|
||||
Spec QuerySpec `json:"spec" required:"true" description:"The query content: an optional name and the query plugin."`
|
||||
Kind qb.RequestType `json:"kind" required:"true"`
|
||||
Spec QuerySpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
type QuerySpec struct {
|
||||
Name string `json:"name,omitempty" description:"An optional label for the query. The name queries refer to each other by lives in the plugin spec."`
|
||||
Plugin QueryPlugin `json:"plugin" required:"true" description:"The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery."`
|
||||
Name string `json:"name,omitempty"`
|
||||
Plugin QueryPlugin `json:"plugin" required:"true"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -109,8 +109,8 @@ type QuerySpec struct {
|
||||
// *TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
|
||||
// discriminated oneOf (see JSONSchemaOneOf).
|
||||
type Variable struct {
|
||||
Kind variable.Kind `json:"kind" required:"true" description:"The variable kind: ListVariable (values from a plugin) or TextVariable (a free-text value)."`
|
||||
Spec any `json:"spec" required:"true" description:"The kind-specific variable spec."`
|
||||
Kind variable.Kind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (Variable) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
@@ -154,8 +154,8 @@ func (Variable) JSONSchemaOneOf() []any {
|
||||
}
|
||||
|
||||
type VariableEnvelope[S any] struct {
|
||||
Kind string `json:"kind" required:"true" description:"The variable kind."`
|
||||
Spec S `json:"spec" required:"true" description:"The kind-specific variable spec."`
|
||||
Kind string `json:"kind" required:"true"`
|
||||
Spec S `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (v VariableEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
@@ -165,15 +165,15 @@ func (v VariableEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
// ListVariableSpec mirrors dashboard.ListVariableSpec (variable.ListSpec
|
||||
// fields + Name) but with a typed VariablePlugin replacing common.Plugin.
|
||||
type ListVariableSpec struct {
|
||||
Display Display `json:"display" required:"true" description:"The label shown for the variable control."`
|
||||
DefaultValue *VariableDefaultValue `json:"defaultValue,omitempty" description:"The initially selected value: a string, or a list of strings when allowMultiple is true."`
|
||||
AllowAllValue bool `json:"allowAllValue" description:"Whether an all option is offered. Requires allowMultiple."`
|
||||
AllowMultiple bool `json:"allowMultiple" description:"Whether several values can be selected at once."`
|
||||
CustomAllValue string `json:"customAllValue" description:"The value meant to replace every value when all is selected. Requires allowAllValue. Stored, but currently ignored by the UI."`
|
||||
CapturingRegexp string `json:"capturingRegexp" description:"A regular expression meant to keep only matching values, reduced to the capture group when one is present. Stored, but not applied by the UI."`
|
||||
Sort ListVariableSpecSort `json:"sort" description:"How the values are ordered: none (default), alphabetical-asc, alphabetical-desc, numerical-asc, numerical-desc, alphabetical-ci-asc or alphabetical-ci-desc."`
|
||||
Plugin VariablePlugin `json:"plugin" description:"Where the values come from: kind is signoz/DynamicVariable (observed attribute values), signoz/QueryVariable (a ClickHouse query) or signoz/CustomVariable (a fixed list), and spec holds that kind's settings."`
|
||||
Name string `json:"name" required:"true" minLength:"1" description:"The handle queries reference as $name. Up to 75 characters from letters, digits, dot, underscore and hyphen, not all digits, unique within the dashboard."`
|
||||
Display Display `json:"display" required:"true"`
|
||||
DefaultValue *VariableDefaultValue `json:"defaultValue,omitempty"`
|
||||
AllowAllValue bool `json:"allowAllValue"`
|
||||
AllowMultiple bool `json:"allowMultiple"`
|
||||
CustomAllValue string `json:"customAllValue"`
|
||||
CapturingRegexp string `json:"capturingRegexp"`
|
||||
Sort ListVariableSpecSort `json:"sort"`
|
||||
Plugin VariablePlugin `json:"plugin"`
|
||||
Name string `json:"name" required:"true" minLength:"1"`
|
||||
}
|
||||
|
||||
// VariableDefaultValue is a list variable's defaultValue: the string | []string
|
||||
@@ -294,10 +294,10 @@ func (s *ListVariableSpecSort) UnmarshalJSON(data []byte) error {
|
||||
// TextVariableSpec replicates dashboard.TextVariableSpec so name can carry the
|
||||
// required/non-empty schema tags perses leaves off.
|
||||
type TextVariableSpec struct {
|
||||
Display Display `json:"display" required:"true" description:"The label shown for the variable control."`
|
||||
Value string `json:"value" required:"true" description:"The default text. Must be non-empty when constant is true."`
|
||||
Constant bool `json:"constant" description:"Whether the value is meant to be fixed. Stored, but not applied by the UI."`
|
||||
Name string `json:"name" required:"true" minLength:"1" description:"The handle queries reference as $name. Up to 75 characters from letters, digits, dot, underscore and hyphen, not all digits, unique within the dashboard."`
|
||||
Display Display `json:"display" required:"true"`
|
||||
Value string `json:"value" required:"true"`
|
||||
Constant bool `json:"constant"`
|
||||
Name string `json:"name" required:"true" minLength:"1"`
|
||||
}
|
||||
|
||||
// validate mirrors perses TextVariableSpec validation. path is the JSON path to
|
||||
@@ -328,8 +328,8 @@ func (s *TextVariableSpec) validate(path string) error {
|
||||
// based on Kind. No plugin is involved, so we reuse the Perses spec types as
|
||||
// leaf imports.
|
||||
type Layout struct {
|
||||
Kind dashboard.LayoutKind `json:"kind" required:"true" description:"The layout kind. Always Grid."`
|
||||
Spec any `json:"spec" required:"true" description:"The grid section: an optional display title and up to 100 items that place panels. Each item has a content.$ref of #/spec/panels/<id> plus x, y, width and height on a 12-column grid: x and y at least 0, width and height at least 1, x plus width at most 12, and no two items overlapping."`
|
||||
Kind dashboard.LayoutKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
// layoutSpecs is the layout sum type factory. Perses only defines
|
||||
@@ -419,8 +419,8 @@ func (Layout) JSONSchemaOneOf() []any {
|
||||
}
|
||||
|
||||
type LayoutEnvelope[S any] struct {
|
||||
Kind string `json:"kind" required:"true" description:"The layout kind."`
|
||||
Spec S `json:"spec" required:"true" description:"The grid section: an optional display title and up to 100 items that place panels. Each item has a content.$ref of #/spec/panels/<id> plus x, y, width and height on a 12-column grid: x and y at least 0, width and height at least 1, x plus width at most 12, and no two items overlapping."`
|
||||
Kind string `json:"kind" required:"true"`
|
||||
Spec S `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (v LayoutEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
|
||||
@@ -2,8 +2,6 @@ package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -31,9 +29,10 @@ func (VariablePluginKind) Enum() []any {
|
||||
}
|
||||
|
||||
type DynamicVariableSpec struct {
|
||||
// This could be extended to a richer selector in the future.
|
||||
Name string `json:"name" validate:"required" required:"true" description:"The attribute key whose observed values populate the variable, such as service.name."`
|
||||
Signal DynamicVariableSignal `json:"signal" required:"true" nullable:"false" description:"The signal the values are read from: traces, logs, metrics or all (default)."`
|
||||
// Name is the name of the attribute being fetched dynamically from the
|
||||
// signal. This could be extended to a richer selector in the future.
|
||||
Name string `json:"name" validate:"required" required:"true"`
|
||||
Signal DynamicVariableSignal `json:"signal" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
// DynamicVariableSignal is the telemetry signal a dynamic variable draws its
|
||||
@@ -79,11 +78,11 @@ func (s *DynamicVariableSignal) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type QueryVariableSpec struct {
|
||||
QueryValue string `json:"queryValue" validate:"required" required:"true" description:"A ClickHouse SQL query returning one column of selectable values; every returned column is offered."`
|
||||
QueryValue string `json:"queryValue" validate:"required" required:"true"`
|
||||
}
|
||||
|
||||
type CustomVariableSpec struct {
|
||||
CustomValue string `json:"customValue" validate:"required" required:"true" description:"The fixed values as a comma-separated list, such as 1,10,100."`
|
||||
CustomValue string `json:"customValue" validate:"required" required:"true"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -188,21 +187,20 @@ func (AIBuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
type PanelPluginKind string
|
||||
|
||||
const (
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
@@ -210,37 +208,37 @@ func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
}
|
||||
|
||||
type TimeSeriesPanelSpec struct {
|
||||
Visualization TimeSeriesVisualization `json:"visualization" description:"Time range and gap handling."`
|
||||
Formatting PanelFormatting `json:"formatting" description:"Unit and decimal precision of the values."`
|
||||
ChartAppearance TimeSeriesChartAppearance `json:"chartAppearance" description:"Line drawing options."`
|
||||
Axes Axes `json:"axes" description:"Y axis bounds and scale."`
|
||||
Legend Legend `json:"legend" description:"Legend placement and series colors."`
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive" description:"Horizontal reference lines drawn at fixed values."`
|
||||
Visualization TimeSeriesVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
ChartAppearance TimeSeriesChartAppearance `json:"chartAppearance"`
|
||||
Axes Axes `json:"axes"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
type TimeSeriesChartAppearance struct {
|
||||
LineInterpolation LineInterpolation `json:"lineInterpolation" description:"How points are joined: linear, spline (default), step_after or step_before."`
|
||||
ShowPoints bool `json:"showPoints" description:"Whether each data point is marked on the line."`
|
||||
LineStyle LineStyle `json:"lineStyle" description:"The line stroke: solid (default) or dashed."`
|
||||
FillMode FillMode `json:"fillMode" description:"The fill under each line: none (default), solid or gradient."`
|
||||
SpanGaps SpanGaps `json:"spanGaps" description:"Whether lines connect across missing points."`
|
||||
LineInterpolation LineInterpolation `json:"lineInterpolation"`
|
||||
ShowPoints bool `json:"showPoints"`
|
||||
LineStyle LineStyle `json:"lineStyle"`
|
||||
FillMode FillMode `json:"fillMode"`
|
||||
SpanGaps SpanGaps `json:"spanGaps"`
|
||||
}
|
||||
|
||||
type BarChartPanelSpec struct {
|
||||
Visualization BarChartVisualization `json:"visualization" description:"Time range, gap handling and stacking."`
|
||||
Formatting PanelFormatting `json:"formatting" description:"Unit and decimal precision of the values."`
|
||||
Axes Axes `json:"axes" description:"Y axis bounds and scale."`
|
||||
Legend Legend `json:"legend" description:"Legend placement and series colors."`
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive" description:"Horizontal reference lines drawn at fixed values."`
|
||||
Visualization BarChartVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
Axes Axes `json:"axes"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
type AreaChartPanelSpec struct {
|
||||
Visualization AreaChartVisualization `json:"visualization" description:"Time range, gap handling and stacking."`
|
||||
Formatting PanelFormatting `json:"formatting" description:"Unit and decimal precision of the values."`
|
||||
ChartAppearance AreaChartAppearance `json:"chartAppearance" description:"Line and fill drawing options."`
|
||||
Axes Axes `json:"axes" description:"Y axis bounds and scale."`
|
||||
Legend Legend `json:"legend" description:"Legend placement and series colors."`
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive" description:"Horizontal reference lines drawn at fixed values."`
|
||||
Visualization AreaChartVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
ChartAppearance AreaChartAppearance `json:"chartAppearance"`
|
||||
Axes Axes `json:"axes"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
// AreaChartAppearance repeats the line-drawing fields rather than embedding
|
||||
@@ -248,83 +246,83 @@ type AreaChartPanelSpec struct {
|
||||
// a duplicated json tag across an embed boundary is resolved by depth, which the
|
||||
// schema reflector does not model.
|
||||
type AreaChartAppearance struct {
|
||||
LineInterpolation LineInterpolation `json:"lineInterpolation" description:"How points are joined: linear, spline (default), step_after or step_before."`
|
||||
ShowPoints bool `json:"showPoints" description:"Whether each data point is marked on the line."`
|
||||
LineStyle LineStyle `json:"lineStyle" description:"The line stroke: solid (default) or dashed."`
|
||||
FillMode AreaFillMode `json:"fillMode" description:"The fill under each line: solid (default) or gradient."`
|
||||
LineInterpolation LineInterpolation `json:"lineInterpolation"`
|
||||
ShowPoints bool `json:"showPoints"`
|
||||
LineStyle LineStyle `json:"lineStyle"`
|
||||
FillMode AreaFillMode `json:"fillMode"`
|
||||
// FillOpacity is a pointer so an omitted field resolves to the kind default at
|
||||
// render time; a plain value would make the Go zero value a transparent fill.
|
||||
FillOpacity *FillOpacity `json:"fillOpacity" description:"The fill opacity from 0 (transparent) to 1 (opaque). Null uses the chart default."`
|
||||
SpanGaps SpanGaps `json:"spanGaps" description:"Whether lines connect across missing points."`
|
||||
FillOpacity *FillOpacity `json:"fillOpacity"`
|
||||
SpanGaps SpanGaps `json:"spanGaps"`
|
||||
}
|
||||
|
||||
type NumberPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization" description:"The time range the value is computed over."`
|
||||
Formatting PanelFormatting `json:"formatting" description:"Unit and decimal precision of the value."`
|
||||
Thresholds []ComparisonThreshold `json:"thresholds" validate:"dive" description:"Rules that recolor the value when it compares to a threshold as specified."`
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
Thresholds []ComparisonThreshold `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
type PieChartPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization" description:"The time range the slices are computed over."`
|
||||
Formatting PanelFormatting `json:"formatting" description:"Unit and decimal precision of the slice values."`
|
||||
Legend Legend `json:"legend" description:"Legend placement and slice colors."`
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
Legend Legend `json:"legend"`
|
||||
}
|
||||
|
||||
type TablePanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization" description:"The time range the rows are computed over."`
|
||||
Formatting TableFormatting `json:"formatting" description:"Per-column units and decimal precision."`
|
||||
Thresholds []TableThreshold `json:"thresholds" validate:"dive" description:"Rules that recolor a cell when its column value compares to a threshold as specified."`
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting TableFormatting `json:"formatting"`
|
||||
Thresholds []TableThreshold `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
type HistogramPanelSpec struct {
|
||||
HistogramBuckets HistogramBuckets `json:"histogramBuckets" description:"How values are grouped into buckets."`
|
||||
Legend Legend `json:"legend" description:"Legend placement and series colors."`
|
||||
HistogramBuckets HistogramBuckets `json:"histogramBuckets"`
|
||||
Legend Legend `json:"legend"`
|
||||
}
|
||||
|
||||
type HistogramBuckets struct {
|
||||
BucketCount *float64 `json:"bucketCount" description:"The approximate target number of buckets. Ignored when bucketWidth is positive. Null uses the chart default."`
|
||||
BucketWidth *float64 `json:"bucketWidth" description:"The width of each bucket in the value unit. A positive value takes precedence over bucketCount; null or 0 lets the chart choose."`
|
||||
MergeAllActiveQueries bool `json:"mergeAllActiveQueries" description:"Whether every series is combined into a single histogram instead of one per series."`
|
||||
BucketCount *float64 `json:"bucketCount"`
|
||||
BucketWidth *float64 `json:"bucketWidth"`
|
||||
MergeAllActiveQueries bool `json:"mergeAllActiveQueries"`
|
||||
}
|
||||
|
||||
type ListPanelSpec struct {
|
||||
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero" validate:"dive" description:"The columns shown for each row, as field keys with name and optional fieldContext, fieldDataType and signal. Empty shows the default columns."`
|
||||
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero" validate:"dive"`
|
||||
}
|
||||
|
||||
type HeatmapPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization" description:"The time range the heatmap covers."`
|
||||
Formatting PanelFormatting `json:"formatting" description:"Unit and decimal precision of the bucket bounds."`
|
||||
Axes HeatmapAxes `json:"axes" description:"The Y axis scale."`
|
||||
Legend Legend `json:"legend" description:"Legend placement and series colors."`
|
||||
ChartAppearance HeatmapChartAppearance `json:"chartAppearance" description:"How bucket counts map to colors."`
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
Axes HeatmapAxes `json:"axes"`
|
||||
Legend Legend `json:"legend"`
|
||||
ChartAppearance HeatmapChartAppearance `json:"chartAppearance"`
|
||||
}
|
||||
|
||||
// HeatmapAxes carries only the Y scale. The shared Axes type models a value
|
||||
// axis with soft bounds, where a heatmap's Y axis is the bucket boundaries the
|
||||
// response already fixed.
|
||||
type HeatmapAxes struct {
|
||||
Y HeatmapAxis `json:"y" description:"The bucket axis."`
|
||||
Y HeatmapAxis `json:"y"`
|
||||
}
|
||||
|
||||
type HeatmapAxis struct {
|
||||
Scale HeatmapYScale `json:"scale" description:"How bucket bounds are spaced on the axis: auto (default), linear, log or symlog."`
|
||||
Scale HeatmapYScale `json:"scale"`
|
||||
}
|
||||
|
||||
type HeatmapChartAppearance struct {
|
||||
Colors HeatmapColors `json:"colors" description:"The color scale applied to bucket counts."`
|
||||
Colors HeatmapColors `json:"colors"`
|
||||
}
|
||||
|
||||
type HeatmapColors struct {
|
||||
Mode HeatmapColorMode `json:"mode" description:"How counts are colored: palette (default) picks a hue from palette, opacity varies the opacity of fill."`
|
||||
Palette HeatmapPalette `json:"palette" description:"The named color ramp used in palette mode, such as lava (default), ice or ember."`
|
||||
Scale HeatmapColorScale `json:"scale" description:"How counts map onto the color range: log (default), sqrt or linear."`
|
||||
Steps int `json:"steps" validate:"omitempty,min=2,max=128" description:"The number of distinct color steps, 2 to 128. 0 uses the chart default."`
|
||||
Mode HeatmapColorMode `json:"mode"`
|
||||
Palette HeatmapPalette `json:"palette"`
|
||||
Scale HeatmapColorScale `json:"scale"`
|
||||
Steps int `json:"steps" validate:"omitempty,min=2,max=128"`
|
||||
// MinCount and MaxCount clamp the colour scale; nil derives them from the
|
||||
// grid, 0 and the highest count in it.
|
||||
MinCount *float64 `json:"minCount" description:"The count mapped to the lowest color. Null uses 0. Must not exceed maxCount."`
|
||||
MaxCount *float64 `json:"maxCount" description:"The count mapped to the highest color. Null uses the highest count in the grid."`
|
||||
MinCount *float64 `json:"minCount"`
|
||||
MaxCount *float64 `json:"maxCount"`
|
||||
// Fill applies in opacity mode; empty means the selected group's legend colour.
|
||||
Fill string `json:"fill" description:"The hex color varied in opacity mode. Empty uses the series legend color."`
|
||||
Fill string `json:"fill"`
|
||||
}
|
||||
|
||||
func (c *HeatmapColors) UnmarshalJSON(data []byte) error {
|
||||
@@ -346,148 +344,16 @@ func (c HeatmapColors) validate() error {
|
||||
}
|
||||
|
||||
type TextPanelSpec struct {
|
||||
Mode TextMode `json:"mode" description:"How text is interpreted. Only markdown is supported."`
|
||||
Text string `json:"text" description:"The content to render."`
|
||||
Presentation TextPresentation `json:"presentation" description:"Alignment and background of the content."`
|
||||
HeaderOptions HeaderOptions `json:"headerOptions" description:"Whether the panel header is shown."`
|
||||
}
|
||||
|
||||
type ScatterPlotPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Dimensions ScatterPlotDimensions `json:"dimensions"`
|
||||
Formatting TableFormatting `json:"formatting"`
|
||||
Axes ScatterPlotAxes `json:"axes"`
|
||||
ChartAppearance ScatterPlotChartAppearance `json:"chartAppearance"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []ScatterPlotThreshold `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
func (s *ScatterPlotPanelSpec) validateAgainstQuery(query Query, path string) error {
|
||||
composite, err := query.Spec.Plugin.buildV5CompositeQueryFromPlugin()
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.dimensions: %s", path, err.Error())
|
||||
}
|
||||
keys, known := composite.CollectResultColumnKeys()
|
||||
if !known {
|
||||
return nil
|
||||
}
|
||||
if err := s.validateQueriesShareGroupBy(composite, path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validateValueDimensions(keys, path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validateColorDimensions(keys, path); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Order is ignored since rows join on sorted labels; disabled queries count, as formulas carry their keys.
|
||||
func (*ScatterPlotPanelSpec) validateQueriesShareGroupBy(composite qb.CompositeQuery, path string) error {
|
||||
var queries []qb.QueryEnvelope
|
||||
for _, envelope := range composite.Queries {
|
||||
if envelope.GetType() != qb.QueryTypeFormula {
|
||||
queries = append(queries, envelope)
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(queries); i++ {
|
||||
first, other := groupByKeyNames(queries[0]), groupByKeyNames(queries[i])
|
||||
if !slices.Equal(first, other) {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: queries must group by the same keys so their rows join into dots; %q groups by [%s], %q groups by [%s]", path, queries[0].GetQueryName(), allowedValuesForKind(first), queries[i].GetQueryName(), allowedValuesForKind(other))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScatterPlotPanelSpec) validateValueDimensions(keys qb.ResultColumnKeys, path string) error {
|
||||
for _, binding := range []struct{ field, key string }{{"x", s.Dimensions.X}, {"y", s.Dimensions.Y}, {"sizeBy", s.Dimensions.SizeBy}} {
|
||||
// Empty is unset: the renderer picks a column or a fixed size.
|
||||
if binding.key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := keys.Aggregations[binding.key]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.%s: %q is not a value column of the panel's query; value columns: %s", path, binding.field, binding.key, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Aggregations))))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScatterPlotPanelSpec) validateColorDimensions(keys qb.ResultColumnKeys, path string) error {
|
||||
for _, label := range s.Dimensions.ColorBy {
|
||||
if _, ok := keys.Groups[label]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.colorBy: %q is not a group-by key of the panel's query; group-by keys: %s", path, label, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Groups))))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func groupByKeyNames(envelope qb.QueryEnvelope) []string {
|
||||
names := make([]string, 0, len(envelope.GetGroupBy()))
|
||||
for _, key := range envelope.GetGroupBy() {
|
||||
names = append(names, key.Name)
|
||||
}
|
||||
slices.Sort(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// ScatterPlotDimensions binds result columns to what a dot encodes. Value columns
|
||||
// use the Table column key, so formatting.columnUnits carries over between the
|
||||
// two kinds.
|
||||
type ScatterPlotDimensions struct {
|
||||
X string `json:"x" description:"Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column."`
|
||||
Y string `json:"y" description:"Value column key plotted on the y axis. Empty uses the second value column."`
|
||||
SizeBy string `json:"sizeBy" description:"Value column key that scales dot size. Empty draws every dot at the default size."`
|
||||
ColorBy []string `json:"colorBy" description:"Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label."`
|
||||
}
|
||||
|
||||
type ScatterPlotAxes struct {
|
||||
X ScatterPlotAxis `json:"x"`
|
||||
Y ScatterPlotAxis `json:"y"`
|
||||
}
|
||||
|
||||
type ScatterPlotAxis struct {
|
||||
SoftMin *float64 `json:"softMin"`
|
||||
SoftMax *float64 `json:"softMax"`
|
||||
Scale AxisScale `json:"scale"`
|
||||
Label string `json:"label" description:"Axis title. Empty draws none."`
|
||||
}
|
||||
|
||||
type ScatterPlotChartAppearance struct {
|
||||
Points ScatterPlotPoints `json:"points"`
|
||||
}
|
||||
|
||||
// ScatterPlotPoints keeps the fixed size and the size range side by side so
|
||||
// binding or unbinding dimensions.sizeBy restores the other's last setting. Nil
|
||||
// fields resolve to the renderer default.
|
||||
type ScatterPlotPoints struct {
|
||||
Size *PointDiameter `json:"size" description:"Diameter of every dot when dimensions.sizeBy is unset."`
|
||||
MinSize *PointDiameter `json:"minSize" description:"Diameter of the smallest dot when dimensions.sizeBy is set."`
|
||||
MaxSize *PointDiameter `json:"maxSize" description:"Diameter of the largest dot when dimensions.sizeBy is set."`
|
||||
Opacity *PointOpacity `json:"opacity"`
|
||||
}
|
||||
|
||||
func (p *ScatterPlotPoints) UnmarshalJSON(data []byte) error {
|
||||
type alias ScatterPlotPoints
|
||||
var tmp alias
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid points")
|
||||
}
|
||||
*p = ScatterPlotPoints(tmp)
|
||||
return p.validate()
|
||||
}
|
||||
|
||||
func (p ScatterPlotPoints) validate() error {
|
||||
if p.MinSize != nil && p.MaxSize != nil && *p.MinSize > *p.MaxSize {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid points: minSize %d must not exceed maxSize %d", *p.MinSize, *p.MaxSize)
|
||||
}
|
||||
return nil
|
||||
Mode TextMode `json:"mode"`
|
||||
Text string `json:"text"`
|
||||
Presentation TextPresentation `json:"presentation"`
|
||||
HeaderOptions HeaderOptions `json:"headerOptions"`
|
||||
}
|
||||
|
||||
type TextPresentation struct {
|
||||
TextAlign TextAlign `json:"textAlign" description:"Horizontal alignment: left (default), center or right."`
|
||||
VerticalAlign VerticalAlign `json:"verticalAlign" description:"Vertical alignment: top (default), center or bottom."`
|
||||
Background *string `json:"background,omitempty" validate:"omitempty,hexcolor" description:"A hex background color such as #1f2937. Omit for the panel default."`
|
||||
TextAlign TextAlign `json:"textAlign"`
|
||||
VerticalAlign VerticalAlign `json:"verticalAlign"`
|
||||
Background *string `json:"background,omitempty" validate:"omitempty,hexcolor"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -495,84 +361,79 @@ type TextPresentation struct {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type Axes struct {
|
||||
SoftMin *float64 `json:"softMin" description:"The lowest value the Y axis always shows; data below it still extends the axis. Null fits the data."`
|
||||
SoftMax *float64 `json:"softMax" description:"The highest value the Y axis always shows; data above it still extends the axis. Null fits the data."`
|
||||
IsLogScale bool `json:"isLogScale" description:"Whether the Y axis uses a logarithmic scale."`
|
||||
SoftMin *float64 `json:"softMin"`
|
||||
SoftMax *float64 `json:"softMax"`
|
||||
IsLogScale bool `json:"isLogScale"`
|
||||
}
|
||||
|
||||
// HeaderOptions controls the panel card's header strip — the title/description
|
||||
// row above the panel content. Phrased as hide so the zero value shows the
|
||||
// header, matching every other panel kind.
|
||||
type HeaderOptions struct {
|
||||
Hide bool `json:"hide" description:"Whether the title and description row above the content is hidden."`
|
||||
Hide bool `json:"hide"`
|
||||
}
|
||||
|
||||
type BasicVisualization struct {
|
||||
TimePreference TimePreference `json:"timePreference" description:"The time range the panel queries: global_time (default) follows the dashboard range; last_5_min, last_15_min, last_30_min, last_1_hr, last_6_hr, last_1_day, last_3_days, last_1_week or last_1_month pin a fixed window."`
|
||||
TimePreference TimePreference `json:"timePreference"`
|
||||
}
|
||||
|
||||
type TimeSeriesVisualization struct {
|
||||
BasicVisualization
|
||||
FillSpans bool `json:"fillSpans" description:"Whether intervals with no data are drawn as zero instead of a gap."`
|
||||
FillSpans bool `json:"fillSpans"`
|
||||
}
|
||||
|
||||
type BarChartVisualization struct {
|
||||
BasicVisualization
|
||||
FillSpans bool `json:"fillSpans" description:"Whether intervals with no data are drawn as zero instead of a gap."`
|
||||
StackedBarChart bool `json:"stackedBarChart" description:"Whether the series are stacked on top of each other."`
|
||||
FillSpans bool `json:"fillSpans"`
|
||||
StackedBarChart bool `json:"stackedBarChart"`
|
||||
}
|
||||
|
||||
type AreaChartVisualization struct {
|
||||
BasicVisualization
|
||||
FillSpans bool `json:"fillSpans" description:"Whether intervals with no data are drawn as zero instead of a gap."`
|
||||
Stack StackMode `json:"stack" description:"How the areas combine: none (default) overlays them, normal stacks the values, percent stacks them as shares of the total."`
|
||||
FillSpans bool `json:"fillSpans"`
|
||||
Stack StackMode `json:"stack"`
|
||||
}
|
||||
|
||||
type PanelFormatting struct {
|
||||
Unit string `json:"unit" description:"The display unit of the values, such as By, ms, s, percent or percentunit. Use percent for values already expressed as percentages (95 displays as 95%) and percentunit for fractions (0.95 displays as 95%). Empty shows plain numbers."`
|
||||
DecimalPrecision PrecisionOption `json:"decimalPrecision" description:"Decimal places shown: 0, 1, 2 (default), 3, 4 or full."`
|
||||
Unit string `json:"unit"`
|
||||
DecimalPrecision PrecisionOption `json:"decimalPrecision"`
|
||||
}
|
||||
|
||||
type TableFormatting struct {
|
||||
ColumnUnits map[string]string `json:"columnUnits" description:"Unit id per value column, keyed by the column key: the query name for a builder query with one aggregation (A), the query name dotted with the aggregation expression for several (A.count()), or the SQL alias for a ClickHouse SQL column. Aggregation aliases are display labels, not keys."`
|
||||
DecimalPrecision PrecisionOption `json:"decimalPrecision" description:"Decimal places shown: 0, 1, 2 (default), 3, 4 or full."`
|
||||
ColumnUnits map[string]string `json:"columnUnits"`
|
||||
DecimalPrecision PrecisionOption `json:"decimalPrecision"`
|
||||
}
|
||||
|
||||
type Legend struct {
|
||||
Position LegendPosition `json:"position" description:"Where the legend sits: bottom (default) or right."`
|
||||
Mode LegendMode `json:"mode" description:"The legend layout. Only list is supported."`
|
||||
CustomColors map[string]string `json:"customColors" description:"Hex colors keyed by series label, overriding the automatic palette."`
|
||||
Position LegendPosition `json:"position"`
|
||||
Mode LegendMode `json:"mode"`
|
||||
CustomColors map[string]string `json:"customColors"`
|
||||
}
|
||||
|
||||
type ThresholdWithLabel struct {
|
||||
// Value is always present in the schema (required:"true"), but 0 is a legitimate
|
||||
// threshold, so it drops validate:"required" — go-playground's required treats a
|
||||
// zero float as unset and would wrongly reject value: 0.
|
||||
Value float64 `json:"value" required:"true" description:"The value the line is drawn at, in unit."`
|
||||
Unit string `json:"unit" description:"The unit id of value, such as ms or percent. Empty means the panel's unit, or the column's unit for a table threshold. Prefer the panel's unit; a different unit works only when it converts to it, such as s against a panel in ms."`
|
||||
Color string `json:"color" validate:"required" required:"true" description:"The CSS color of the line, for example #F1575F, which matches the SigNoz red preset; CSS named colors such as red also work."`
|
||||
Label string `json:"label" description:"The caption shown on the line."`
|
||||
Value float64 `json:"value" required:"true"`
|
||||
Unit string `json:"unit"`
|
||||
Color string `json:"color" validate:"required" required:"true"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type ComparisonThreshold struct {
|
||||
// Value is always present in the schema (required:"true"), but 0 is a legitimate
|
||||
// threshold, so it drops validate:"required" — go-playground's required treats a
|
||||
// zero float as unset and would wrongly reject value: 0.
|
||||
Value float64 `json:"value" required:"true" description:"The value compared against, in unit."`
|
||||
Operator ComparisonOperator `json:"operator" description:"The comparison that activates the threshold: above (default), below, above_or_equal, below_or_equal, equal or not_equal."`
|
||||
Unit string `json:"unit" description:"The unit id of value, such as ms or percent. Empty means the panel's unit, or the column's unit for a table threshold. Prefer the panel's unit; a different unit works only when it converts to it, such as s against a panel in ms."`
|
||||
Color string `json:"color" validate:"required" required:"true" description:"The CSS color applied to the text or background, per format, while the threshold is active. For example #F1575F matches the SigNoz red preset; CSS named colors such as red also work."`
|
||||
Format ThresholdFormat `json:"format" description:"What the color is applied to: text (default) or background."`
|
||||
}
|
||||
|
||||
type ScatterPlotThreshold struct {
|
||||
ThresholdWithLabel
|
||||
Axis ThresholdAxis `json:"axis"`
|
||||
Value float64 `json:"value" required:"true"`
|
||||
Operator ComparisonOperator `json:"operator"`
|
||||
Unit string `json:"unit"`
|
||||
Color string `json:"color" validate:"required" required:"true"`
|
||||
Format ThresholdFormat `json:"format"`
|
||||
}
|
||||
|
||||
type TableThreshold struct {
|
||||
ComparisonThreshold
|
||||
ColumnName string `json:"columnName" validate:"required" required:"true" description:"The key of the value column the rule applies to: the query name for a builder query with one aggregation (A), the query name dotted with the aggregation expression for several (A.count()), or the SQL alias for a ClickHouse SQL column. Group-by columns are never colored, and aggregation aliases are display labels, not keys."`
|
||||
ColumnName string `json:"columnName" validate:"required" required:"true"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -855,86 +716,6 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
// AxisScale `auto` leaves the choice to the renderer; `symlog` is log-like but
|
||||
// defined at and below zero.
|
||||
type AxisScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
AxisScaleAuto = AxisScale{valuer.NewString("auto")} // default
|
||||
AxisScaleLinear = AxisScale{valuer.NewString("linear")}
|
||||
AxisScaleLog = AxisScale{valuer.NewString("log")}
|
||||
AxisScaleSymlog = AxisScale{valuer.NewString("symlog")}
|
||||
)
|
||||
|
||||
func (AxisScale) Enum() []any {
|
||||
return []any{AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog}
|
||||
}
|
||||
|
||||
func (as AxisScale) ValueOrDefault() string {
|
||||
if as.IsZero() {
|
||||
return AxisScaleAuto.StringValue()
|
||||
}
|
||||
return as.StringValue()
|
||||
}
|
||||
|
||||
func (as AxisScale) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(as.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (as *AxisScale) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid axis scale: must be a string, one of `auto`, `linear`, `log`, or `symlog`")
|
||||
}
|
||||
val := AxisScale{valuer.NewString(v)}
|
||||
switch val {
|
||||
case AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog:
|
||||
*as = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid axis scale %q: must be `auto`, `linear`, `log`, or `symlog`", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ThresholdAxis is the axis a scatter plot threshold line marks: `y` draws it
|
||||
// horizontally, `x` vertically.
|
||||
type ThresholdAxis struct{ valuer.String }
|
||||
|
||||
var (
|
||||
ThresholdAxisX = ThresholdAxis{valuer.NewString("x")}
|
||||
ThresholdAxisY = ThresholdAxis{valuer.NewString("y")} // default
|
||||
)
|
||||
|
||||
func (ThresholdAxis) Enum() []any {
|
||||
return []any{ThresholdAxisX, ThresholdAxisY}
|
||||
}
|
||||
|
||||
func (ta ThresholdAxis) ValueOrDefault() string {
|
||||
if ta.IsZero() {
|
||||
return ThresholdAxisY.StringValue()
|
||||
}
|
||||
return ta.StringValue()
|
||||
}
|
||||
|
||||
func (ta ThresholdAxis) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(ta.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (ta *ThresholdAxis) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid threshold axis: must be a string, one of `x` or `y`")
|
||||
}
|
||||
val := ThresholdAxis{valuer.NewString(v)}
|
||||
switch val {
|
||||
case ThresholdAxisX, ThresholdAxisY:
|
||||
*ta = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid threshold axis %q: must be `x` or `y`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type FillMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
@@ -1073,56 +854,9 @@ func (o *FillOpacity) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
minPointDiameter = 2
|
||||
maxPointDiameter = 40
|
||||
)
|
||||
|
||||
// PointDiameter is a scatter dot's diameter in whole CSS pixels.
|
||||
type PointDiameter int
|
||||
|
||||
func (PointDiameter) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(minPointDiameter).WithMaximum(maxPointDiameter)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *PointDiameter) UnmarshalJSON(data []byte) error {
|
||||
var v int
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point diameter: must be a whole number between %d and %d", minPointDiameter, maxPointDiameter)
|
||||
}
|
||||
if v < minPointDiameter || v > maxPointDiameter {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point diameter %d: must be a whole number between %d and %d", v, minPointDiameter, maxPointDiameter)
|
||||
}
|
||||
*d = PointDiameter(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
const minPointOpacity = 0.1
|
||||
|
||||
// PointOpacity is a scatter dot's fill alpha. The floor keeps a dot visible.
|
||||
type PointOpacity float64
|
||||
|
||||
func (PointOpacity) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(minPointOpacity).WithMaximum(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *PointOpacity) UnmarshalJSON(data []byte) error {
|
||||
var v float64
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point opacity: must be a number between %v and 1", minPointOpacity)
|
||||
}
|
||||
if v < minPointOpacity || v > 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point opacity %v: must be between %v and 1", v, minPointOpacity)
|
||||
}
|
||||
*o = PointOpacity(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
type SpanGaps struct {
|
||||
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
|
||||
FillLessThan string `json:"fillLessThan" description:"The largest gap still connected when fillOnlyBelow is true; longer gaps are left disconnected. Required in that case, as a positive Prometheus duration such as 30s, 5m or 1d."`
|
||||
FillLessThan string `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`
|
||||
}
|
||||
|
||||
func (sg *SpanGaps) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -32,25 +32,25 @@ type PublicDashboard struct {
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
|
||||
TimeRangeEnabled bool `json:"timeRangeEnabled" description:"Whether anonymous viewers may change the time range."`
|
||||
DefaultTimeRange string `json:"defaultTimeRange" description:"The time range shown to anonymous viewers by default, as a Go duration such as 30m, 1h or 24h. Accepted units are ns, us, ms, s, m and h; write a day as 24h."`
|
||||
DashboardID valuer.UUID `json:"dashboardId" description:"The id of the dashboard being shared."`
|
||||
TimeRangeEnabled bool `json:"timeRangeEnabled"`
|
||||
DefaultTimeRange string `json:"defaultTimeRange"`
|
||||
DashboardID valuer.UUID `json:"dashboardId"`
|
||||
}
|
||||
|
||||
type GettablePublicDasbhboard struct {
|
||||
TimeRangeEnabled bool `json:"timeRangeEnabled" description:"Whether anonymous viewers may change the time range."`
|
||||
DefaultTimeRange string `json:"defaultTimeRange" description:"The time range shown to anonymous viewers by default, as a Go duration such as 30m, 1h or 24h. Accepted units are ns, us, ms, s, m and h; write a day as 24h."`
|
||||
PublicPath string `json:"publicPath" description:"The path segment of the public URL."`
|
||||
TimeRangeEnabled bool `json:"timeRangeEnabled"`
|
||||
DefaultTimeRange string `json:"defaultTimeRange"`
|
||||
PublicPath string `json:"publicPath"`
|
||||
}
|
||||
|
||||
type PostablePublicDashboard struct {
|
||||
TimeRangeEnabled bool `json:"timeRangeEnabled" description:"Whether anonymous viewers may change the time range."`
|
||||
DefaultTimeRange string `json:"defaultTimeRange" description:"The time range shown to anonymous viewers by default, as a Go duration such as 30m, 1h or 24h. Accepted units are ns, us, ms, s, m and h; write a day as 24h."`
|
||||
TimeRangeEnabled bool `json:"timeRangeEnabled"`
|
||||
DefaultTimeRange string `json:"defaultTimeRange"`
|
||||
}
|
||||
|
||||
type UpdatablePublicDashboard struct {
|
||||
TimeRangeEnabled bool `json:"timeRangeEnabled" description:"Whether anonymous viewers may change the time range."`
|
||||
DefaultTimeRange string `json:"defaultTimeRange" description:"The time range shown to anonymous viewers by default, as a Go duration such as 30m, 1h or 24h. Accepted units are ns, us, ms, s, m and h; write a day as 24h."`
|
||||
TimeRangeEnabled bool `json:"timeRangeEnabled"`
|
||||
DefaultTimeRange string `json:"defaultTimeRange"`
|
||||
}
|
||||
|
||||
func NewPublicDashboard(timeRangeEnabled bool, defaultTimeRange string, dashboardID valuer.UUID) *PublicDashboard {
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
{
|
||||
"display": {
|
||||
"name": "service health",
|
||||
"description": "throughput against tail latency per pod"
|
||||
},
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "throughput vs p99",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/ScatterPlotPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "last_1_hr"
|
||||
},
|
||||
"dimensions": {
|
||||
"x": "A.count()",
|
||||
"y": "A.p99(duration_nano)",
|
||||
"sizeBy": "A.countIf(has_error = true)",
|
||||
"colorBy": ["k8s.namespace.name", "k8s.pod.name"]
|
||||
},
|
||||
"formatting": {
|
||||
"columnUnits": {
|
||||
"A.p99(duration_nano)": "ns"
|
||||
},
|
||||
"decimalPrecision": "1"
|
||||
},
|
||||
"axes": {
|
||||
"x": {
|
||||
"softMin": 0,
|
||||
"softMax": null,
|
||||
"scale": "log",
|
||||
"label": "Throughput"
|
||||
},
|
||||
"y": {
|
||||
"softMin": null,
|
||||
"softMax": 1000,
|
||||
"scale": "symlog",
|
||||
"label": "p99 latency"
|
||||
}
|
||||
},
|
||||
"chartAppearance": {
|
||||
"points": {
|
||||
"size": 8,
|
||||
"minSize": 4,
|
||||
"maxSize": 24,
|
||||
"opacity": 0.1
|
||||
}
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "list",
|
||||
"customColors": {
|
||||
"checkout": "#1f77b4"
|
||||
}
|
||||
},
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 300,
|
||||
"unit": "ms",
|
||||
"color": "#f00",
|
||||
"label": "p99 SLO",
|
||||
"axis": "y"
|
||||
},
|
||||
{
|
||||
"value": 50,
|
||||
"color": "#0f0",
|
||||
"axis": "x"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"queries": [
|
||||
{
|
||||
"kind": "scalar",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()"
|
||||
},
|
||||
{
|
||||
"expression": "p99(duration_nano)"
|
||||
},
|
||||
{
|
||||
"expression": "countIf(has_error = true)"
|
||||
}
|
||||
],
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "k8s.namespace.name"
|
||||
},
|
||||
{
|
||||
"name": "k8s.pod.name"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 12,
|
||||
"height": 8,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/p1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -41,12 +41,12 @@ func (TreemapMode) Enum() []any {
|
||||
|
||||
// StatsRequest represents the payload accepted by the metrics stats endpoint.
|
||||
type StatsRequest struct {
|
||||
Filter *qbtypes.Filter `json:"filter,omitempty" description:"Query builder filter over metric attributes, for example host.name = 'web-1'. A bare word matches the metric name. Omitting it applies no restriction, but metrics whose name starts with signoz are always excluded."`
|
||||
Start int64 `json:"start" required:"true" description:"Start of the time range as a Unix timestamp in milliseconds. Must be greater than 0 and less than end."`
|
||||
End int64 `json:"end" required:"true" description:"End of the time range as a Unix timestamp in milliseconds. Must be greater than start."`
|
||||
Limit int `json:"limit" required:"true" description:"Maximum number of metrics to return, between 1 and 5000."`
|
||||
Offset int `json:"offset" description:"Number of metrics to skip for pagination. Must not be negative; defaults to 0."`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy,omitempty" description:"Sort order; key.name is timeseries or samples, direction is asc or desc. Defaults to samples descending."`
|
||||
Filter *qbtypes.Filter `json:"filter,omitempty"`
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
Offset int `json:"offset"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy,omitempty"`
|
||||
}
|
||||
|
||||
// Validate ensures StatsRequest contains acceptable values.
|
||||
@@ -104,26 +104,26 @@ func (req *StatsRequest) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// Stat represents the summary information returned per metric.
|
||||
type Stat struct {
|
||||
MetricName string `json:"metricName" required:"true" description:"The name of the metric."`
|
||||
Description string `json:"description" required:"true" description:"Human-readable description from the metric metadata; empty when none was reported."`
|
||||
MetricType metrictypes.Type `json:"type" required:"true" description:"The metric type, such as gauge, sum, histogram, summary or exponentialhistogram."`
|
||||
MetricUnit string `json:"unit" required:"true" description:"The unit reported in the metric metadata, for example ms or By; empty when none was reported."`
|
||||
TimeSeries uint64 `json:"timeseries" required:"true" description:"Number of distinct time series active in the time range, approximate: it can include series last active shortly before start."`
|
||||
Samples uint64 `json:"samples" required:"true" description:"Number of samples (data points) ingested for the metric in the requested time range."`
|
||||
MetricName string `json:"metricName" required:"true"`
|
||||
Description string `json:"description" required:"true"`
|
||||
MetricType metrictypes.Type `json:"type" required:"true"`
|
||||
MetricUnit string `json:"unit" required:"true"`
|
||||
TimeSeries uint64 `json:"timeseries" required:"true"`
|
||||
Samples uint64 `json:"samples" required:"true"`
|
||||
}
|
||||
|
||||
// StatsResponse represents the aggregated metrics statistics.
|
||||
type StatsResponse struct {
|
||||
Metrics []Stat `json:"metrics" required:"true" nullable:"true" description:"One entry per metric on the requested page, in the requested order."`
|
||||
Total uint64 `json:"total" required:"true" description:"Total number of metrics matching the filter across all pages."`
|
||||
Metrics []Stat `json:"metrics" required:"true" nullable:"true"`
|
||||
Total uint64 `json:"total" required:"true"`
|
||||
}
|
||||
|
||||
type MetricMetadata struct {
|
||||
Description string `json:"description" required:"true" description:"Human-readable description of the metric; empty when none was reported."`
|
||||
MetricType metrictypes.Type `json:"type" required:"true" description:"The metric type, such as gauge, sum, histogram, summary or exponentialhistogram."`
|
||||
MetricUnit string `json:"unit" required:"true" description:"The unit of the metric values, for example ms or By; empty when none was reported."`
|
||||
Temporality metrictypes.Temporality `json:"temporality" required:"true" description:"Aggregation temporality of the metric: cumulative, delta or unspecified."`
|
||||
IsMonotonic bool `json:"isMonotonic" required:"true" description:"True when the metric is a monotonically increasing Sum (a counter)."`
|
||||
Description string `json:"description" required:"true"`
|
||||
MetricType metrictypes.Type `json:"type" required:"true"`
|
||||
MetricUnit string `json:"unit" required:"true"`
|
||||
Temporality metrictypes.Temporality `json:"temporality" required:"true"`
|
||||
IsMonotonic bool `json:"isMonotonic" required:"true"`
|
||||
}
|
||||
|
||||
// MarshalBinary implements cachetypes.Cacheable interface.
|
||||
@@ -138,21 +138,21 @@ func (m *MetricMetadata) UnmarshalBinary(data []byte) error {
|
||||
|
||||
// UpdateMetricMetadataRequest represents the payload for updating metric metadata.
|
||||
type UpdateMetricMetadataRequest struct {
|
||||
MetricName string `json:"metricName" required:"true" description:"The name of the metric whose metadata to replace."`
|
||||
Type metrictypes.Type `json:"type" required:"true" description:"The metric type to store, such as gauge, sum, histogram, summary or exponentialhistogram."`
|
||||
Description string `json:"description" required:"true" description:"Human-readable description of the metric."`
|
||||
Unit string `json:"unit" required:"true" description:"The unit of the metric values, for example ms or By."`
|
||||
Temporality metrictypes.Temporality `json:"temporality" required:"true" description:"Aggregation temporality to store. Required as delta or cumulative when type is sum, histogram or exponentialhistogram; gauges are stored as unspecified and summaries as cumulative whatever is sent. A non-monotonic cumulative sum is stored as a gauge."`
|
||||
IsMonotonic bool `json:"isMonotonic" required:"true" description:"Whether the metric is a monotonically increasing Sum (a counter)."`
|
||||
MetricName string `json:"metricName" required:"true"`
|
||||
Type metrictypes.Type `json:"type" required:"true"`
|
||||
Description string `json:"description" required:"true"`
|
||||
Unit string `json:"unit" required:"true"`
|
||||
Temporality metrictypes.Temporality `json:"temporality" required:"true"`
|
||||
IsMonotonic bool `json:"isMonotonic" required:"true"`
|
||||
}
|
||||
|
||||
// TreemapRequest represents the payload for the metrics treemap endpoint.
|
||||
type TreemapRequest struct {
|
||||
Filter *qbtypes.Filter `json:"filter,omitempty" description:"Query builder filter over metric attributes, for example host.name = 'web-1'. A bare word matches the metric name. Omitting it applies no restriction, but metrics whose name starts with signoz are always excluded."`
|
||||
Start int64 `json:"start" required:"true" description:"Start of the time range as a Unix timestamp in milliseconds. Must be greater than 0 and less than end."`
|
||||
End int64 `json:"end" required:"true" description:"End of the time range as a Unix timestamp in milliseconds. Must be greater than start."`
|
||||
Limit int `json:"limit" required:"true" description:"Maximum number of metrics to return, between 1 and 5000, ordered by percentage descending."`
|
||||
Mode TreemapMode `json:"mode" required:"true" description:"What each metric is sized by: timeseries counts distinct time series; samples counts ingested data points, ranked among a shortlist of the metrics with the most time series. Only the matching response array is populated."`
|
||||
Filter *qbtypes.Filter `json:"filter,omitempty"`
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
Mode TreemapMode `json:"mode" required:"true"`
|
||||
}
|
||||
|
||||
// Validate enforces basic constraints on TreemapRequest.
|
||||
@@ -218,32 +218,32 @@ func (req *TreemapRequest) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// TreemapEntry represents each node in the treemap response.
|
||||
type TreemapEntry struct {
|
||||
MetricName string `json:"metricName" required:"true" description:"The name of the metric."`
|
||||
Percentage float64 `json:"percentage" required:"true" description:"Share of the total across all metrics in the time range, from 0 to 100."`
|
||||
TotalValue uint64 `json:"totalValue" required:"true" description:"Time series count or sample count for the metric, depending on the requested mode."`
|
||||
MetricName string `json:"metricName" required:"true"`
|
||||
Percentage float64 `json:"percentage" required:"true"`
|
||||
TotalValue uint64 `json:"totalValue" required:"true"`
|
||||
}
|
||||
|
||||
// TreemapResponse is the output structure for the treemap endpoint.
|
||||
type TreemapResponse struct {
|
||||
TimeSeries []TreemapEntry `json:"timeseries" required:"true" nullable:"true" description:"Entries sized by time series count; populated only when mode is timeseries."`
|
||||
Samples []TreemapEntry `json:"samples" required:"true" nullable:"true" description:"Entries sized by sample count, chosen among the metrics with the most time series; populated only when mode is samples."`
|
||||
TimeSeries []TreemapEntry `json:"timeseries" required:"true" nullable:"true"`
|
||||
Samples []TreemapEntry `json:"samples" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
// MetricAlert represents an alert associated with a metric.
|
||||
type MetricAlert struct {
|
||||
AlertName string `json:"alertName" required:"true" description:"The name of the alert rule that queries the metric."`
|
||||
AlertID string `json:"alertId" required:"true" description:"The id of the alert rule."`
|
||||
AlertName string `json:"alertName" required:"true"`
|
||||
AlertID string `json:"alertId" required:"true"`
|
||||
}
|
||||
|
||||
// MetricAlertsResponse represents the response for metric alerts endpoint.
|
||||
type MetricAlertsResponse struct {
|
||||
Alerts []MetricAlert `json:"alerts" required:"true" nullable:"true" description:"Alert rules whose queries reference the metric."`
|
||||
Alerts []MetricAlert `json:"alerts" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
// MetricDashboardPanelsResponse is the response for the v2 metric dashboards
|
||||
// endpoint: the dashboard panels that reference the metric.
|
||||
type MetricDashboardPanelsResponse struct {
|
||||
Dashboards []dashboardtypes.DashboardPanelRef `json:"dashboards" required:"true" nullable:"true" description:"One entry per dashboard panel whose query references the metric."`
|
||||
Dashboards []dashboardtypes.DashboardPanelRef `json:"dashboards" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
// NewMetricDashboardPanelsResponse wraps the dashboard panels that reference a
|
||||
@@ -258,10 +258,10 @@ func NewMetricDashboardPanelsResponse(refs []dashboardtypes.DashboardPanelRef) *
|
||||
|
||||
// MetricHighlightsResponse is the output structure for the metric highlights endpoint.
|
||||
type MetricHighlightsResponse struct {
|
||||
DataPoints uint64 `json:"dataPoints" required:"true" description:"Number of samples ingested for the metric over retained data."`
|
||||
LastReceived uint64 `json:"lastReceived" required:"true" description:"Unix timestamp in milliseconds of the most recently received sample."`
|
||||
TotalTimeSeries uint64 `json:"totalTimeSeries" required:"true" description:"Number of distinct time series for the metric over retained data."`
|
||||
ActiveTimeSeries uint64 `json:"activeTimeSeries" required:"true" description:"Number of distinct time series active in the last 120 minutes, approximate: a series that last reported early in that window can be missed."`
|
||||
DataPoints uint64 `json:"dataPoints" required:"true"`
|
||||
LastReceived uint64 `json:"lastReceived" required:"true"`
|
||||
TotalTimeSeries uint64 `json:"totalTimeSeries" required:"true"`
|
||||
ActiveTimeSeries uint64 `json:"activeTimeSeries" required:"true"`
|
||||
}
|
||||
|
||||
// MetricNameQuery represents the query parameters for endpoints that take a metric name.
|
||||
@@ -308,24 +308,24 @@ func (req *MetricAttributesRequest) Validate() error {
|
||||
|
||||
// MetricAttribute represents a single attribute with its values and count.
|
||||
type MetricAttribute struct {
|
||||
Key string `json:"key" required:"true" description:"The attribute (label) name."`
|
||||
Values []string `json:"values" required:"true" nullable:"true" description:"Distinct values observed for the attribute, capped at 1000."`
|
||||
ValueCount uint64 `json:"valueCount" required:"true" description:"Total number of distinct values observed, which can exceed the length of values."`
|
||||
Key string `json:"key" required:"true"`
|
||||
Values []string `json:"values" required:"true" nullable:"true"`
|
||||
ValueCount uint64 `json:"valueCount" required:"true"`
|
||||
}
|
||||
|
||||
// MetricAttributesResponse is the output structure for the metric attributes endpoint.
|
||||
type MetricAttributesResponse struct {
|
||||
Attributes []MetricAttribute `json:"attributes" required:"true" nullable:"true" description:"Attributes of the metric, ordered by distinct value count descending."`
|
||||
TotalKeys int64 `json:"totalKeys" required:"true" description:"Number of attributes returned."`
|
||||
Attributes []MetricAttribute `json:"attributes" required:"true" nullable:"true"`
|
||||
TotalKeys int64 `json:"totalKeys" required:"true"`
|
||||
}
|
||||
|
||||
// ListMetricsParams represents the query parameters for the list metrics endpoint.
|
||||
type ListMetricsParams struct {
|
||||
Start *int64 `query:"start" description:"Start of the time range as a Unix timestamp in milliseconds. Must be greater than 0; applied only when end is also set."`
|
||||
End *int64 `query:"end" description:"End of the time range as a Unix timestamp in milliseconds. Must be greater than start; applied only when start is also set."`
|
||||
Limit int `query:"limit" description:"Maximum number of metric names to return. Defaults to 100; must not exceed 5000."`
|
||||
Search string `query:"searchText" description:"Case-insensitive substring to match against metric names."`
|
||||
Source string `query:"source" description:"Set to meter to list Cost Meter metrics instead of telemetry metrics."`
|
||||
Start *int64 `query:"start"`
|
||||
End *int64 `query:"end"`
|
||||
Limit int `query:"limit"`
|
||||
Search string `query:"searchText"`
|
||||
Source string `query:"source"`
|
||||
}
|
||||
|
||||
// Validate ensures ListMetricsParams contains acceptable values.
|
||||
@@ -353,30 +353,30 @@ func (p *ListMetricsParams) Validate() error {
|
||||
|
||||
// ListMetric represents a single metric with its metadata in the list metrics response.
|
||||
type ListMetric struct {
|
||||
MetricName string `json:"metricName" required:"true" description:"The name of the metric."`
|
||||
Description string `json:"description" required:"true" description:"Human-readable description from the metric metadata; empty when none was reported."`
|
||||
MetricType metrictypes.Type `json:"type" required:"true" description:"The metric type, such as gauge, sum, histogram, summary or exponentialhistogram."`
|
||||
MetricUnit string `json:"unit" required:"true" description:"The unit reported in the metric metadata, for example ms or By; empty when none was reported."`
|
||||
Temporality metrictypes.Temporality `json:"temporality" required:"true" description:"Aggregation temporality of the metric: cumulative, delta or unspecified."`
|
||||
IsMonotonic bool `json:"isMonotonic" required:"true" description:"True when the metric is a monotonically increasing Sum (a counter)."`
|
||||
MetricName string `json:"metricName" required:"true"`
|
||||
Description string `json:"description" required:"true"`
|
||||
MetricType metrictypes.Type `json:"type" required:"true"`
|
||||
MetricUnit string `json:"unit" required:"true"`
|
||||
Temporality metrictypes.Temporality `json:"temporality" required:"true"`
|
||||
IsMonotonic bool `json:"isMonotonic" required:"true"`
|
||||
}
|
||||
|
||||
// ListMetricsResponse represents the response for the list metrics endpoint.
|
||||
type ListMetricsResponse struct {
|
||||
Metrics []ListMetric `json:"metrics" required:"true" nullable:"true" description:"Matching metrics with their metadata, ordered by name."`
|
||||
Metrics []ListMetric `json:"metrics" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
// MetricsOnboardingResponse is the response for the lightweight metrics onboarding check.
|
||||
type MetricsOnboardingResponse struct {
|
||||
HasMetrics bool `json:"hasMetrics" required:"true" description:"True when at least one metric not emitted by SigNoz itself has been ingested."`
|
||||
HasMetrics bool `json:"hasMetrics" required:"true"`
|
||||
}
|
||||
|
||||
// InspectMetricsRequest is the request for inspecting raw metric data points.
|
||||
type InspectMetricsRequest struct {
|
||||
MetricName string `json:"metricName" required:"true" description:"The name of the metric to inspect."`
|
||||
Start int64 `json:"start" required:"true" description:"Start of the time range as a Unix timestamp in milliseconds. Must be greater than 0 and less than end."`
|
||||
End int64 `json:"end" required:"true" description:"End of the time range as a Unix timestamp in milliseconds. At most 30 minutes after start."`
|
||||
Filter *qbtypes.Filter `json:"filter,omitempty" description:"Query builder filter over the metric attributes, for example host.name = 'web-1'. Omitting it applies no attribute restriction; inspection returns at most 50 series either way."`
|
||||
MetricName string `json:"metricName" required:"true"`
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Filter *qbtypes.Filter `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
// Validate ensures the request is valid.
|
||||
@@ -399,5 +399,5 @@ func (r *InspectMetricsRequest) Validate() error {
|
||||
|
||||
// InspectMetricsResponse is the response for the inspect metrics endpoint.
|
||||
type InspectMetricsResponse struct {
|
||||
Series []*qbtypes.TimeSeries `json:"series" required:"true" nullable:"true" description:"Raw time series, at most 50, each with its labels and timestamp-value pairs."`
|
||||
Series []*qbtypes.TimeSeries `json:"series" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
@@ -277,8 +277,8 @@ type MetricValueFilter struct {
|
||||
}
|
||||
|
||||
type ComparisonSpaceAggregationParam struct {
|
||||
Operater string `json:"operator" required:"true" description:"Comparison applied to histogram bucket bounds when spaceAggregation is count: <= gives the estimated count of observations at or below threshold, > the estimated count above it."`
|
||||
Threshold float64 `json:"threshold" required:"true" description:"Bucket bound the comparison uses, in the unit of the histogram metric. The count is exact when threshold equals one of the metric's le bucket bounds, and otherwise an estimate interpolated linearly between the two nearest bounds."`
|
||||
Operater string `json:"operator" required:"true"`
|
||||
Threshold float64 `json:"threshold" required:"true"`
|
||||
}
|
||||
|
||||
func (param ComparisonSpaceAggregationParam) StringValue() string {
|
||||
|
||||
@@ -497,8 +497,10 @@ func FunctionReduceTo(result *TimeSeries, reduceTo ReduceTo) *TimeSeries {
|
||||
}
|
||||
|
||||
type TraceAggregation struct {
|
||||
Expression string `json:"expression" description:"The aggregation over spans, such as count(), sum(item_price), p99(duration_nano) or countIf(day > 10)."`
|
||||
Alias string `json:"alias,omitempty" description:"The name the aggregation is reported under; when omitted, the result carries the alias __result_N, with N the zero-based index of the aggregation."`
|
||||
// aggregation expression - example: count(), sum(item_price), countIf(day > 10)
|
||||
Expression string `json:"expression"`
|
||||
// if any, it will be used as the alias of the aggregation in the result
|
||||
Alias string `json:"alias,omitempty"`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of TraceAggregation.
|
||||
@@ -507,8 +509,10 @@ func (t TraceAggregation) Copy() TraceAggregation {
|
||||
}
|
||||
|
||||
type LogAggregation struct {
|
||||
Expression string `json:"expression" description:"The aggregation over log records, such as count(), sum(item_price), count_distinct(user_id) or countIf(day > 10)."`
|
||||
Alias string `json:"alias,omitempty" description:"The name the aggregation is reported under; when omitted, the result carries the alias __result_N, with N the zero-based index of the aggregation."`
|
||||
// aggregation expression - example: count(), sum(item_price), countIf(day > 10)
|
||||
Expression string `json:"expression"`
|
||||
// if any, it will be used as the alias of the aggregation in the result
|
||||
Alias string `json:"alias,omitempty"`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of LogAggregation.
|
||||
@@ -517,18 +521,24 @@ func (l LogAggregation) Copy() LogAggregation {
|
||||
}
|
||||
|
||||
type MetricAggregation struct {
|
||||
MetricName string `json:"metricName" description:"The name of the metric to query, for example k8s.pod.cpu_request_utilization."`
|
||||
// metric to query
|
||||
MetricName string `json:"metricName"`
|
||||
// type of the metric
|
||||
Type metrictypes.Type `json:"-"`
|
||||
Temporality metrictypes.Temporality `json:"temporality" description:"The temporality of the metric: delta, cumulative or unspecified. Resolved from the metric metadata when omitted."`
|
||||
TimeAggregation metrictypes.TimeAggregation `json:"timeAggregation" description:"How samples of one series are combined within each step, such as avg, sum, min, max, count, count_distinct, latest, rate or increase."`
|
||||
SpaceAggregation metrictypes.SpaceAggregation `json:"spaceAggregation" description:"How series are combined across each group, such as sum, avg, min, max or count; histograms take percentiles such as p50, p90, p95 or p99."`
|
||||
ComparisonSpaceAggregationParam *metrictypes.ComparisonSpaceAggregationParam `json:"comparisonSpaceAggregationParam,omitempty" description:"Histogram metrics with spaceAggregation count only: the comparison operator and bucket bound used to estimate how many observations fall at or below, or above, the threshold. Ignored for other metric types and aggregations."`
|
||||
Type metrictypes.Type `json:"-"`
|
||||
// temporality to apply to the query
|
||||
Temporality metrictypes.Temporality `json:"temporality"`
|
||||
// time aggregation to apply to the query
|
||||
TimeAggregation metrictypes.TimeAggregation `json:"timeAggregation"`
|
||||
// space aggregation to apply to the query
|
||||
SpaceAggregation metrictypes.SpaceAggregation `json:"spaceAggregation"`
|
||||
// param for space aggregation if needed
|
||||
ComparisonSpaceAggregationParam *metrictypes.ComparisonSpaceAggregationParam `json:"comparisonSpaceAggregationParam,omitempty"`
|
||||
// table hints to use for the query
|
||||
TableHints *metrictypes.MetricTableHints `json:"-"`
|
||||
// value filter to apply to the query
|
||||
ValueFilter *metrictypes.MetricValueFilter `json:"-"`
|
||||
ReduceTo ReduceTo `json:"reduceTo,omitzero" description:"How a series collapses to one number in scalar requests: sum, count, avg, min, max, last or median. Ignored by time_series requests."`
|
||||
// reduce to operator for metric scalar requests
|
||||
ReduceTo ReduceTo `json:"reduceTo,omitzero"`
|
||||
|
||||
HeatmapBucketing *HeatmapBucketing `json:"-"`
|
||||
|
||||
@@ -554,7 +564,8 @@ func (m MetricAggregation) Copy() MetricAggregation {
|
||||
}
|
||||
|
||||
type Filter struct {
|
||||
Expression string `json:"expression" description:"A filter in the query filter syntax: comparisons such as =, !=, <, >, IN, LIKE, CONTAINS and EXISTS on field names, combined with AND, OR and NOT, with string values in single quotes, for example key = 'value' AND other_key > 10. Which fields exist depends on the signal or endpoint the filter is used with."`
|
||||
// expression to filter by following the filter syntax
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of Filter.
|
||||
@@ -577,7 +588,8 @@ func (g GroupByKey) Copy() GroupByKey {
|
||||
}
|
||||
|
||||
type Having struct {
|
||||
Expression string `json:"expression" description:"A condition on aggregation results in the filter syntax, referencing an alias or expression, for example count() > 100."`
|
||||
// expression to filter by following the filter syntax
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of Having.
|
||||
@@ -601,8 +613,10 @@ func (o OrderByKey) Copy() OrderByKey {
|
||||
|
||||
// key to order by.
|
||||
type OrderBy struct {
|
||||
Key OrderByKey `json:"key" description:"The sort key. For aggregation requests name is a group-by field such as service.name, an aggregation alias, or the aggregation expression: count() or p99(duration_nano) for traces and logs; for metrics the spaceAggregation(timeAggregation(metricName)) form such as sum(rate(signoz_calls_total)), p99(metricName) for histogram metrics, or __result. Raw requests sort by any field, such as timestamp."`
|
||||
Direction OrderDirection `json:"direction" description:"The sort direction: asc or desc."`
|
||||
// key to order by
|
||||
Key OrderByKey `json:"key"`
|
||||
// direction to order by
|
||||
Direction OrderDirection `json:"direction"`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of OrderBy.
|
||||
@@ -615,13 +629,21 @@ func (o OrderBy) Copy() OrderBy {
|
||||
|
||||
// secondary aggregation to apply to the query.
|
||||
type SecondaryAggregation struct {
|
||||
StepInterval Step `json:"stepInterval,omitzero" description:"The time bucket for this aggregation: seconds as a number or a Go duration string. Uses the primary aggregation step when omitted."`
|
||||
Expression string `json:"expression" description:"The aggregation over the primary results, such as count(), sum(item_price) or countIf(day > 10)."`
|
||||
Alias string `json:"alias,omitempty" description:"The name the aggregation is reported under; when omitted, the result carries the alias __result_N, with N the zero-based index of the aggregation."`
|
||||
GroupBy []GroupByKey `json:"groupBy,omitzero" description:"The fields to group this aggregation by."`
|
||||
Order []OrderBy `json:"order,omitzero" description:"The sort order of this aggregation result."`
|
||||
Limit int `json:"limit,omitempty" description:"The maximum number of groups to return. Unlimited when 0."`
|
||||
LimitBy *LimitBy `json:"limitBy,omitempty" description:"Applies limit within each distinct combination of the given keys."`
|
||||
// stepInterval of the query
|
||||
// if not set, it will use the step interval of the primary aggregation
|
||||
StepInterval Step `json:"stepInterval,omitzero"`
|
||||
// expression to aggregate. example: count(), sum(item_price), countIf(day > 10)
|
||||
Expression string `json:"expression"`
|
||||
// if any, it will be used as the alias of the aggregation in the result
|
||||
Alias string `json:"alias,omitempty"`
|
||||
// groupBy fields to group by
|
||||
GroupBy []GroupByKey `json:"groupBy,omitzero"`
|
||||
// order by keys and directions
|
||||
Order []OrderBy `json:"order,omitzero"`
|
||||
// limit the maximum number of rows to return
|
||||
Limit int `json:"limit,omitempty"`
|
||||
// limitBy fields to limit by
|
||||
LimitBy *LimitBy `json:"limitBy,omitempty"`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of SecondaryAggregation.
|
||||
@@ -650,8 +672,10 @@ func (s SecondaryAggregation) Copy() SecondaryAggregation {
|
||||
}
|
||||
|
||||
type FunctionArg struct {
|
||||
Name string `json:"name,omitempty" description:"The argument name for functions that take named arguments, such as z_score_threshold or seasonality for anomaly."`
|
||||
Value any `json:"value" description:"The argument value as a number, string or boolean, for example the threshold for cutOffMin or the duration for timeShift."`
|
||||
// name of the argument
|
||||
Name string `json:"name,omitempty"`
|
||||
// value of the argument
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of FunctionArg.
|
||||
@@ -671,7 +695,6 @@ func (FunctionArg) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
}
|
||||
|
||||
value := jsonschema.Schema{}
|
||||
value.Description = propertyDescription(s, "value")
|
||||
value.OneOf = []jsonschema.SchemaOrBool{
|
||||
jsonschema.Number.ToSchemaOrBool(),
|
||||
jsonschema.String.ToSchemaOrBool(),
|
||||
@@ -682,9 +705,11 @@ func (FunctionArg) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
}
|
||||
|
||||
type Function struct {
|
||||
Name FunctionName `json:"name" description:"The function to apply, such as cutOffMin, cutOffMax, clampMin, clampMax, absolute, runningDiff, log2, log10, cumulativeSum, ewma3, ewma5, ewma7, median3, median5, median7, timeShift, anomaly or fillZero."`
|
||||
// name of the function
|
||||
Name FunctionName `json:"name"`
|
||||
|
||||
Args []FunctionArg `json:"args,omitzero" description:"The function arguments; which ones are required depends on the function, for example a threshold for the cutOff and clamp functions."`
|
||||
// args is the arguments to the function
|
||||
Args []FunctionArg `json:"args,omitzero"`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of Function.
|
||||
@@ -714,8 +739,10 @@ func (f Function) Validate() error {
|
||||
}
|
||||
|
||||
type LimitBy struct {
|
||||
Keys []string `json:"keys" description:"The group-by field names that partition the result before the limit is applied."`
|
||||
Value string `json:"value" description:"The aggregation alias or expression whose value ranks entries within each partition."`
|
||||
// keys to limit by
|
||||
Keys []string `json:"keys"`
|
||||
// value to limit by
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of LimitBy.
|
||||
@@ -732,13 +759,3 @@ func (l *LimitBy) Copy() *LimitBy {
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// propertyDescription returns the description the struct tag put on a reflected
|
||||
// property, so a PrepareJSONSchema hook that replaces the property schema keeps it.
|
||||
func propertyDescription(s *jsonschema.Schema, name string) *string {
|
||||
prop, ok := s.Properties[name]
|
||||
if !ok || prop.TypeObject == nil {
|
||||
return nil
|
||||
}
|
||||
return prop.TypeObject.Description
|
||||
}
|
||||
|
||||
@@ -12,43 +12,65 @@ import (
|
||||
)
|
||||
|
||||
type QueryBuilderQuery[T any] struct {
|
||||
Name string `json:"name" description:"The unique name of the query, such as A or B; formulas, trace operators and selectedQueryName refer to it."`
|
||||
// name of the query, mainly used when query is used in formula
|
||||
Name string `json:"name"`
|
||||
|
||||
StepInterval Step `json:"stepInterval,omitzero" description:"The time bucket for time_series aggregation: seconds as a number or a Go duration string such as 60s or 1m. Derived from the time range when omitted."`
|
||||
// stepInterval of the query
|
||||
StepInterval Step `json:"stepInterval,omitzero"`
|
||||
|
||||
Signal telemetrytypes.Signal `json:"signal,omitempty" description:"The telemetry signal to query: traces, logs or metrics. It selects the aggregation shape and is required for builder_query."`
|
||||
// signal to query
|
||||
Signal telemetrytypes.Signal `json:"signal,omitempty"`
|
||||
|
||||
Source telemetrytypes.Source `json:"source" description:"The data source within the signal. Set to meter for Cost Meter metrics; leave empty for regular telemetry."`
|
||||
// source for query
|
||||
Source telemetrytypes.Source `json:"source"`
|
||||
|
||||
Aggregations []T `json:"aggregations,omitzero" description:"The aggregations to compute, one result per entry. Traces and logs take expressions such as count() or p99(duration_nano); metrics take a metricName with time and space aggregations. Omit for raw requests."`
|
||||
// we want to support multiple aggregations
|
||||
// currently supported: []Aggregation, []MetricAggregation
|
||||
Aggregations []T `json:"aggregations,omitzero"`
|
||||
|
||||
BucketOptions *BucketOptions `json:"bucketOptions,omitempty" description:"The value axis to count this query into, as a kind-discriminated linear or log bucket spec. Read only by heatmap requests, and only from the query they draw."`
|
||||
// BucketOptions is the bucket axis to count this query's values into. Only a
|
||||
// heatmap request reads it, and only from the query it draws.
|
||||
BucketOptions *BucketOptions `json:"bucketOptions,omitempty"`
|
||||
|
||||
Disabled bool `json:"disabled" description:"When true, the result is excluded from the response; the query may still run and can still feed a formula or trace operator. Defaults to false."`
|
||||
// disabled if true, the query will not be executed
|
||||
Disabled bool `json:"disabled"`
|
||||
|
||||
Filter *Filter `json:"filter,omitempty" description:"The filter expression that selects the rows or series of this signal."`
|
||||
// search query is simple string
|
||||
Filter *Filter `json:"filter,omitempty"`
|
||||
|
||||
GroupBy []GroupByKey `json:"groupBy,omitzero" description:"The fields to group aggregations by; each group becomes a series or a row with the field as a label or column."`
|
||||
// group by keys to group by
|
||||
GroupBy []GroupByKey `json:"groupBy,omitzero"`
|
||||
|
||||
Order []OrderBy `json:"order,omitzero" description:"The sort order of the result. Order by a group-by field, an aggregation alias, or for raw requests a selected field such as timestamp."`
|
||||
// order by keys and directions
|
||||
Order []OrderBy `json:"order,omitzero"`
|
||||
|
||||
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero" description:"The fields to return for each row in raw requests, in addition to the columns always present: timestamp and id for logs, timestamp, trace_id and span_id for traces. Ignored by aggregating requests."`
|
||||
// select columns to select
|
||||
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero"`
|
||||
|
||||
Limit int `json:"limit,omitempty" description:"The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest."`
|
||||
// limit the maximum number of rows to return
|
||||
Limit int `json:"limit,omitempty"`
|
||||
|
||||
LimitBy *LimitBy `json:"limitBy,omitempty" description:"Reserved for applying limit within each combination of the given group-by keys. Accepted but currently ignored."`
|
||||
// limitBy fields to limit by
|
||||
LimitBy *LimitBy `json:"limitBy,omitempty"`
|
||||
|
||||
Offset int `json:"offset,omitempty" description:"The number of rows to skip before returning results. Prefer cursor for raw requests."`
|
||||
// offset the number of rows to skip
|
||||
// TODO: remove this once we have cursor-based pagination everywhere?
|
||||
Offset int `json:"offset,omitempty"`
|
||||
|
||||
Cursor string `json:"cursor,omitempty" description:"The opaque nextCursor value from a previous raw result, used to fetch the next page."`
|
||||
// cursor to paginate the query
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
|
||||
Having *Having `json:"having,omitempty" description:"A filter applied to aggregation results, referencing aliases or expressions, for example count() > 100."`
|
||||
// having clause to apply to the query
|
||||
Having *Having `json:"having,omitempty"`
|
||||
|
||||
SecondaryAggregations []SecondaryAggregation `json:"secondaryAggregations,omitzero" description:"Accepted but currently ignored, including their nested order, limit and limitBy."`
|
||||
// secondary aggregation to apply to the query
|
||||
// on top of the primary aggregation
|
||||
SecondaryAggregations []SecondaryAggregation `json:"secondaryAggregations,omitzero"`
|
||||
|
||||
Functions []Function `json:"functions,omitzero" description:"Post-processing functions applied in order to each result series, such as absolute, cumulativeSum, timeShift or anomaly."`
|
||||
// functions to apply to the query
|
||||
Functions []Function `json:"functions,omitzero"`
|
||||
|
||||
Legend string `json:"legend" description:"The display name template for each series; {{field}} placeholders are replaced by group-by label values."`
|
||||
Legend string `json:"legend"`
|
||||
|
||||
// ShiftBy is extracted from timeShift function for internal use
|
||||
// This field is not serialized to JSON
|
||||
@@ -74,9 +96,7 @@ func (QueryBuilderQuery[T]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return nil
|
||||
}
|
||||
prop := (&jsonschema.Schema{}).WithType(jsonschema.String.Type()).WithEnum(signal.StringValue())
|
||||
prop.Description = propertyDescription(s, "signal")
|
||||
s.Properties["signal"] = prop.ToSchemaOrBool()
|
||||
describePropertyForSignal(s, "filter", filterDescriptionsBySignal[signal])
|
||||
if !slices.Contains(s.Required, "signal") {
|
||||
s.Required = append(s.Required, "signal")
|
||||
}
|
||||
@@ -272,23 +292,3 @@ func CanShortCircuitReduced(metricAgg MetricAggregation) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// filterDescriptionsBySignal gives each QueryBuilderQuery instantiation a filter
|
||||
// example drawn from its own signal, so the traces example never shows up on a
|
||||
// logs or metrics query. The shared Filter schema stays signal-neutral.
|
||||
var filterDescriptionsBySignal = map[telemetrytypes.Signal]string{
|
||||
telemetrytypes.SignalTraces: "The filter that selects spans, in the query filter syntax, for example service.name = 'frontend' AND http.response.status_code >= 500.",
|
||||
telemetrytypes.SignalLogs: "The filter that selects log records, in the query filter syntax, for example severity_text = 'ERROR' AND body CONTAINS 'timeout'.",
|
||||
telemetrytypes.SignalMetrics: "The filter that selects series by their attributes, in the query filter syntax, for example k8s.namespace.name = 'prod' AND k8s.pod.name LIKE 'api%'.",
|
||||
}
|
||||
|
||||
// describePropertyForSignal replaces a property's description with a
|
||||
// signal-specific one, keeping whatever schema shape the property already has.
|
||||
func describePropertyForSignal(s *jsonschema.Schema, name, description string) {
|
||||
prop, ok := s.Properties[name]
|
||||
if !ok || prop.TypeObject == nil || description == "" {
|
||||
return
|
||||
}
|
||||
prop.TypeObject.Description = &description
|
||||
s.Properties[name] = prop
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
type ClickHouseQuery struct {
|
||||
Name string `json:"name" description:"The unique name of the query, such as A."`
|
||||
Query string `json:"query" description:"The ClickHouse SQL statement. Reserved time placeholders such as {{.start_timestamp}}, {{.end_timestamp}}, {{.start_timestamp_ms}} and {{.start_datetime}} and dashboard variables written as {{name}}, [[name]] or $name are substituted before execution."`
|
||||
Disabled bool `json:"disabled" description:"When true, the result is excluded from the response but the query may still run. Defaults to false."`
|
||||
// name of the query
|
||||
Name string `json:"name"`
|
||||
// query to execute
|
||||
Query string `json:"query"`
|
||||
// disabled if true, the query will not be executed
|
||||
Disabled bool `json:"disabled"`
|
||||
|
||||
Legend string `json:"legend" description:"The display name template for each series; {{column}} placeholders are replaced by result column values."`
|
||||
Legend string `json:"legend"`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of the ClickHouseQuery.
|
||||
|
||||
@@ -17,22 +17,30 @@ import (
|
||||
)
|
||||
|
||||
type QueryBuilderFormula struct {
|
||||
Name string `json:"name" description:"The unique name of the formula, such as F1, used by selectedQueryName and by panels. Referencing a formula from another formula is not reliable."`
|
||||
Expression string `json:"expression" description:"An arithmetic expression over builder query names, for example A / B * 100. Reference queries, not other formulas."`
|
||||
// name of the formula
|
||||
Name string `json:"name"`
|
||||
// expression to apply to the query
|
||||
Expression string `json:"expression"`
|
||||
|
||||
Disabled bool `json:"disabled" description:"When true, the result is excluded from the response; the formula may still be evaluated. Defaults to false."`
|
||||
Disabled bool `json:"disabled"`
|
||||
|
||||
Order []OrderBy `json:"order,omitzero" description:"The sort order of the formula result."`
|
||||
// order by keys and directions
|
||||
Order []OrderBy `json:"order,omitzero"`
|
||||
|
||||
Limit int `json:"limit,omitempty" description:"The maximum number of series or rows to return. Unlimited when 0."`
|
||||
// limit the maximum number of rows to return
|
||||
Limit int `json:"limit,omitempty"`
|
||||
|
||||
Having *Having `json:"having,omitempty" description:"Accepted but currently ignored; the computed values are returned unfiltered."`
|
||||
// having clause to apply to the formula result
|
||||
Having *Having `json:"having,omitempty"`
|
||||
|
||||
Functions []Function `json:"functions,omitzero" description:"Post-processing functions applied in order to each result series."`
|
||||
// functions to apply to the formula result
|
||||
Functions []Function `json:"functions,omitzero"`
|
||||
|
||||
BucketOptions *BucketOptions `json:"bucketOptions,omitempty" description:"The value axis to count the formula result into, as a kind-discriminated linear or log bucket spec. Read only by heatmap requests, and only from the query they draw."`
|
||||
// BucketOptions is the bucket axis to count the formula's results into.
|
||||
// Only a heatmap request reads it, and only from the query it draws.
|
||||
BucketOptions *BucketOptions `json:"bucketOptions,omitempty"`
|
||||
|
||||
Legend string `json:"legend" description:"The display name template for each series; {{field}} placeholders are replaced by label values."`
|
||||
Legend string `json:"legend"`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of the QueryBuilderFormula.
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
type PromQuery struct {
|
||||
Name string `json:"name" description:"The unique name of the query, such as A."`
|
||||
Query string `json:"query" description:"The PromQL expression. Dotted metric and label names must use the UTF-8 quoted selector form, for example {'http.server.duration'}."`
|
||||
Disabled bool `json:"disabled" description:"When true, the result is excluded from the response but the query may still run. Defaults to false."`
|
||||
Step Step `json:"step" description:"The evaluation step: seconds as a number or a Go duration string such as 60s. Derived from the time range when omitted."`
|
||||
Stats bool `json:"stats" description:"Reserved; currently has no effect."`
|
||||
SkipStepAlignment bool `json:"skipStepAlignment,omitempty" description:"When true, start and end are evaluated as given instead of rounded down to the step, and the result is not cached. Defaults to false."`
|
||||
// name of the query
|
||||
Name string `json:"name"`
|
||||
// query to execute
|
||||
Query string `json:"query"`
|
||||
// disabled if true, the query will not be executed
|
||||
Disabled bool `json:"disabled"`
|
||||
// step size for the query
|
||||
Step Step `json:"step"`
|
||||
// stats if true, the query will return stats
|
||||
Stats bool `json:"stats"`
|
||||
// skipStepAlignment if true, start and end are used as given instead of
|
||||
// rounded down to the step, and the result is never cached
|
||||
SkipStepAlignment bool `json:"skipStepAlignment,omitempty"`
|
||||
|
||||
Legend string `json:"legend" description:"The display name template for each series; {{label}} placeholders are replaced by label values."`
|
||||
Legend string `json:"legend"`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of the PromQuery.
|
||||
|
||||
@@ -25,17 +25,17 @@ type Result struct {
|
||||
}
|
||||
|
||||
type ExecStats struct {
|
||||
RowsScanned uint64 `json:"rowsScanned" description:"Rows scanned to answer every query in the request. A cost indicator only: rows are scanned whether or not any matched, so it says nothing about whether data exists."`
|
||||
BytesScanned uint64 `json:"bytesScanned" description:"Bytes scanned to answer every query in the request. A cost indicator only; it does not indicate whether any data matched."`
|
||||
DurationMS uint64 `json:"durationMs" description:"Total execution time across the queries in the request, in milliseconds. A cost indicator only, not the request latency and not a sign that data exists."`
|
||||
StepIntervals map[string]uint64 `json:"stepIntervals,omitempty" description:"The step interval in seconds that was used for each query, keyed by query name."`
|
||||
RowsScanned uint64 `json:"rowsScanned"`
|
||||
BytesScanned uint64 `json:"bytesScanned"`
|
||||
DurationMS uint64 `json:"durationMs"`
|
||||
StepIntervals map[string]uint64 `json:"stepIntervals,omitempty"`
|
||||
}
|
||||
|
||||
var _ jsonschema.Preparer = &ExecStats{}
|
||||
|
||||
// PrepareJSONSchema adds description to the ExecStats schema.
|
||||
func (e *ExecStats) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
schema.WithDescription("Execution cost of the request: rows and bytes scanned and execution time. A measure of cost, not of whether data exists.")
|
||||
schema.WithDescription("Execution statistics for the query, including rows scanned, bytes scanned, and duration.")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,10 @@ import (
|
||||
)
|
||||
|
||||
type QueryEnvelope struct {
|
||||
Type QueryType `json:"type" description:"The kind of query the spec holds: builder_query, builder_ai_query, builder_formula, builder_trace_operator, promql or clickhouse_sql."`
|
||||
Spec any `json:"spec" description:"The query specification; its shape is selected by type, and by signal for builder_query."`
|
||||
// Type is the type of the query.
|
||||
Type QueryType `json:"type"` // "builder_query" | "builder_formula" | "builder_sub_query" | "builder_join" | "promql" | "clickhouse_sql"
|
||||
// Spec is the deferred decoding of the query if any.
|
||||
Spec any `json:"spec"`
|
||||
}
|
||||
|
||||
// builderQuerySpec is a signal-discriminated oneOf of the three
|
||||
@@ -260,44 +262,8 @@ func UnmarshalBuilderQueryBySignal(data []byte) (any, error) {
|
||||
}
|
||||
|
||||
type CompositeQuery struct {
|
||||
Queries []QueryEnvelope `json:"queries" description:"The queries to run, each wrapped in a type-discriminated envelope. Names must be unique; formulas and trace operators reference other queries by name."`
|
||||
}
|
||||
|
||||
// ResultColumnKeys is what a scalar query yields per row, keyed the way the
|
||||
// UI keys columns: an aggregation column is the query name, or
|
||||
// queryName.expression when the query has several aggregations; a group
|
||||
// column is a group-by key name.
|
||||
type ResultColumnKeys struct {
|
||||
Aggregations map[string]struct{}
|
||||
Groups map[string]struct{}
|
||||
}
|
||||
|
||||
// CollectResultColumnKeys reports false when a query's columns are only known
|
||||
// at run time, as for ClickHouse SQL and PromQL. A disabled query yields no
|
||||
// aggregation column but keeps its group columns, since a formula over it
|
||||
// still carries them.
|
||||
func (c *CompositeQuery) CollectResultColumnKeys() (ResultColumnKeys, bool) {
|
||||
keys := ResultColumnKeys{Aggregations: map[string]struct{}{}, Groups: map[string]struct{}{}}
|
||||
for _, envelope := range c.Queries {
|
||||
if envelope.GetType() == QueryTypeClickHouseSQL || envelope.GetType() == QueryTypePromQL {
|
||||
return ResultColumnKeys{}, false
|
||||
}
|
||||
for _, key := range envelope.GetGroupBy() {
|
||||
keys.Groups[key.Name] = struct{}{}
|
||||
}
|
||||
if envelope.IsDisabled() {
|
||||
continue
|
||||
}
|
||||
expressions := envelope.GetAggregationExpressions()
|
||||
if len(expressions) <= 1 {
|
||||
keys.Aggregations[envelope.GetQueryName()] = struct{}{}
|
||||
continue
|
||||
}
|
||||
for _, expression := range expressions {
|
||||
keys.Aggregations[envelope.GetQueryName()+"."+expression] = struct{}{}
|
||||
}
|
||||
}
|
||||
return keys, true
|
||||
// Queries is the queries to use for the request.
|
||||
Queries []QueryEnvelope `json:"queries"`
|
||||
}
|
||||
|
||||
// PrepareJSONSchema adds description to the CompositeQuery schema.
|
||||
@@ -367,8 +333,8 @@ func (VariableType) Enum() []any {
|
||||
}
|
||||
|
||||
type VariableItem struct {
|
||||
Type VariableType `json:"type" description:"The kind of dashboard variable: query, dynamic, custom or text."`
|
||||
Value any `json:"value" description:"The selected value: a string, number or boolean, or a list of them for multi-select variables. The string __all__ is special only for dynamic variables in builder filters and PromQL, where it removes the condition that references the variable; query, custom and text variables, and ClickHouse SQL for every type, substitute it literally."`
|
||||
Type VariableType `json:"type"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
var _ jsonschema.Preparer = VariableItem{}
|
||||
@@ -394,7 +360,6 @@ func (VariableItem) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
list.WithItems(items)
|
||||
|
||||
value := jsonschema.Schema{}
|
||||
value.Description = propertyDescription(s, "value")
|
||||
value.OneOf = []jsonschema.SchemaOrBool{
|
||||
jsonschema.String.ToSchemaOrBool(),
|
||||
jsonschema.Number.ToSchemaOrBool(),
|
||||
@@ -407,21 +372,28 @@ func (VariableItem) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
}
|
||||
|
||||
type QueryRangeRequest struct {
|
||||
SchemaVersion string `json:"schemaVersion" description:"The version of the request payload schema, currently v1."`
|
||||
Start uint64 `json:"start" description:"The inclusive start of the time range as a Unix timestamp in milliseconds."`
|
||||
End uint64 `json:"end" description:"The end of the time range as a Unix timestamp in milliseconds; must be greater than start except for raw_stream requests."`
|
||||
RequestType RequestType `json:"requestType" description:"The shape of the result: scalar, time_series, raw, raw_stream, trace, distribution or heatmap. It decides which result type data.results carries."`
|
||||
CompositeQuery CompositeQuery `json:"compositeQuery" description:"The queries to execute together; at least one is required and formulas may reference the others by name."`
|
||||
Variables map[string]VariableItem `json:"variables,omitempty" description:"Dashboard variable values keyed by variable name. Builder filters substitute only the $name form and leave a quoted {{name}} unchanged; PromQL and ClickHouse SQL substitute $name, {{name}}, [[name]] and the Go template form {{.name}}."`
|
||||
// SchemaVersion is the version of the schema to use for the request payload.
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
// Start is the start time of the query in epoch milliseconds.
|
||||
Start uint64 `json:"start"`
|
||||
// End is the end time of the query in epoch milliseconds.
|
||||
End uint64 `json:"end"`
|
||||
// RequestType is the type of the request.
|
||||
RequestType RequestType `json:"requestType"`
|
||||
// CompositeQuery is the composite query to use for the request.
|
||||
CompositeQuery CompositeQuery `json:"compositeQuery"`
|
||||
// Variables is the variables to use for the request.
|
||||
Variables map[string]VariableItem `json:"variables,omitempty"`
|
||||
|
||||
NoCache bool `json:"noCache,omitempty" description:"When true, results are computed fresh instead of being served from the query result cache. Defaults to false."`
|
||||
// NoCache is a flag to disable caching for the request.
|
||||
NoCache bool `json:"noCache,omitempty"`
|
||||
|
||||
FormatOptions *FormatOptions `json:"formatOptions,omitempty" description:"Post-processing applied to the result before it is returned."`
|
||||
FormatOptions *FormatOptions `json:"formatOptions,omitempty"`
|
||||
}
|
||||
|
||||
// PrepareJSONSchema adds description to the QueryRangeRequest schema.
|
||||
func (q *QueryRangeRequest) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
schema.WithDescription("Request body for the v5 query range endpoint. Supports builder queries (traces, logs, metrics), formulas, trace operators, PromQL, and ClickHouse SQL queries.")
|
||||
schema.WithDescription("Request body for the v5 query range endpoint. Supports builder queries (traces, logs, metrics), formulas, joins, trace operators, PromQL, and ClickHouse SQL queries.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -705,8 +677,8 @@ func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type FormatOptions struct {
|
||||
FillGaps bool `json:"fillGaps,omitempty" description:"When true, every builder query and formula in a time_series result gets a zero value for each missing step, whatever its aggregation, so averages and percentiles read as zero where no data exists. PromQL and ClickHouse SQL queries keep their gaps. Defaults to false."`
|
||||
FormatTableResultForUI bool `json:"formatTableResultForUI,omitempty" description:"When true, scalar results from several queries are merged into one table keyed by their group-by columns. Defaults to false."`
|
||||
FillGaps bool `json:"fillGaps,omitempty"`
|
||||
FormatTableResultForUI bool `json:"formatTableResultForUI,omitempty"`
|
||||
}
|
||||
|
||||
func (r *QueryRangeRequest) GetQueriesSupportingZeroDefault() map[string]bool {
|
||||
@@ -781,8 +753,8 @@ func (BucketsKind) Enum() []any {
|
||||
type LinearBucketsSpec struct {
|
||||
// Everything above MaxValue is counted in the trailing overflow band. Evenly
|
||||
// spaced upper bounds have no top to divide without it, so it is required.
|
||||
MaxValue float64 `json:"maxValue" required:"true" description:"The upper bound of the last regular band; values above it are counted in a trailing overflow band."`
|
||||
NumBuckets int `json:"numBuckets,omitempty" description:"The number of evenly spaced bands between 0 and maxValue. A default applies when omitted or 0."`
|
||||
MaxValue float64 `json:"maxValue" required:"true"`
|
||||
NumBuckets int `json:"numBuckets,omitempty"`
|
||||
}
|
||||
|
||||
// LogBucketsSpec spaces upper bounds at 2^Scale bands per doubling, the mapping
|
||||
@@ -791,7 +763,7 @@ type LogBucketsSpec struct {
|
||||
// ClickHouse always buckets at MaxLogScale and the surplus is folded away
|
||||
// afterwards, so every Scale reads the same cache entry. MaxLogScale applies
|
||||
// when unset.
|
||||
Scale *int `json:"scale,omitempty" description:"The exponential histogram scale: 2^scale bands per doubling of the value. The maximum supported scale applies when omitted."`
|
||||
Scale *int `json:"scale,omitempty"`
|
||||
}
|
||||
|
||||
func (b *BucketOptions) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -225,34 +225,6 @@ func (q *QueryEnvelope) GetGroupBy() []GroupByKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAggregationExpressions returns one entry per aggregation. A metric
|
||||
// aggregation has no expression, so it contributes an empty entry.
|
||||
func (q *QueryEnvelope) GetAggregationExpressions() []string {
|
||||
switch spec := q.Spec.(type) {
|
||||
case QueryBuilderTraceOperator:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[TraceAggregation]:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[LogAggregation]:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[MetricAggregation]:
|
||||
return make([]string, len(spec.Aggregations))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFilter returns the filter.
|
||||
func (q *QueryEnvelope) GetFilter() *Filter {
|
||||
switch spec := q.Spec.(type) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user