mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-02 00:10:30 +01:00
Compare commits
6 Commits
issue_4360
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
825a07c9fe | ||
|
|
0c9f237369 | ||
|
|
8b13f004ed | ||
|
|
8c1d13bb38 | ||
|
|
ad8f3328e0 | ||
|
|
cc3da72aa5 |
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.121.0
|
||||
image: signoz/signoz:v0.121.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.121.0
|
||||
image: signoz/signoz:v0.121.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.121.0}
|
||||
image: signoz/signoz:${VERSION:-v0.121.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.121.0}
|
||||
image: signoz/signoz:${VERSION:-v0.121.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -17273,9 +17273,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- VIEWER
|
||||
summary: Get host info from Zeus.
|
||||
tags:
|
||||
- zeus
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/cache/memorycache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule/impllmpricingrule"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
|
||||
@@ -113,11 +112,9 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
}
|
||||
|
||||
// initiate agent config handler
|
||||
llmCostFeature := impllmpricingrule.NewLLMCostFeature(signoz.Modules.LLMPricingRule)
|
||||
|
||||
agentConfMgr, err := agentConf.Initiate(&agentConf.ManagerOptions{
|
||||
Store: signoz.SQLStore,
|
||||
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController, llmCostFeature},
|
||||
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -38,4 +38,5 @@ export enum LOCALSTORAGE {
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
}
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
.overview-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.overview-settings {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
|
||||
&.cross-panel-sync {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.cross-panel-sync__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.cross-panel-sync__title {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.cross-panel-sync__description {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.name-icon-input {
|
||||
display: flex;
|
||||
.dashboard-image-input {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Col, Input, Select, Space, Typography } from 'antd';
|
||||
import { Col, Input, Radio, Select, Space, Typography } from 'antd';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
@@ -19,6 +21,10 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
|
||||
const updateDashboardMutation = useUpdateDashboard();
|
||||
|
||||
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(
|
||||
dashboardData?.id,
|
||||
);
|
||||
|
||||
const selectedData = dashboardData?.data;
|
||||
|
||||
const {
|
||||
@@ -159,6 +165,28 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col className="overview-settings cross-panel-sync">
|
||||
<div className="cross-panel-sync__info">
|
||||
<Typography.Text className="cross-panel-sync__title">
|
||||
Cross-Panel Sync
|
||||
</Typography.Text>
|
||||
<Typography.Text className="cross-panel-sync__description">
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={cursorSyncMode}
|
||||
onChange={(e): void => {
|
||||
setCursorSyncMode(e.target.value as DashboardCursorSync);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Crosshair}>
|
||||
Crosshair
|
||||
</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Col>
|
||||
{numberOfUnsavedChanges > 0 && (
|
||||
<div className="overview-settings-footer">
|
||||
<div className="unsaved">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
@@ -14,6 +16,9 @@ import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
|
||||
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
import get from 'lodash/get';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { PanelMode } from '../types';
|
||||
|
||||
function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
@@ -33,6 +38,9 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
|
||||
const [syncMode] = useDashboardCursorSyncMode(dashboardId);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
@@ -74,6 +82,11 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
maxTimeScale,
|
||||
timezone,
|
||||
panelMode,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
syncMode,
|
||||
]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
@@ -114,13 +127,21 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
}, []);
|
||||
|
||||
const groupBy = useMemo(() => {
|
||||
return widget.query.builder.queryData[0].groupBy;
|
||||
return get(widget, 'query.builder.queryData[0].groupBy', []);
|
||||
}, [widget.query]);
|
||||
|
||||
const cursorSyncMode = useMemo(() => {
|
||||
if (panelMode !== PanelMode.DASHBOARD_VIEW) {
|
||||
return DashboardCursorSync.None;
|
||||
}
|
||||
return syncMode;
|
||||
}, [syncMode, panelMode]);
|
||||
|
||||
return (
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
key={cursorSyncMode}
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
@@ -137,6 +158,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone}
|
||||
syncMode={cursorSyncMode}
|
||||
>
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
|
||||
@@ -3,17 +3,22 @@ import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSe
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { ContextMenu } from 'periscope/components/ContextMenu';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
import { PanelMode } from '../types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
@@ -32,6 +37,9 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
|
||||
const [syncMode] = useDashboardCursorSyncMode(dashboardId);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
@@ -80,6 +88,11 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
timezone,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
syncMode,
|
||||
]);
|
||||
|
||||
const layoutChildren = useMemo(() => {
|
||||
@@ -105,13 +118,21 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
]);
|
||||
|
||||
const groupBy = useMemo(() => {
|
||||
return widget.query.builder.queryData[0].groupBy;
|
||||
return get(widget, 'query.builder.queryData[0].groupBy', []);
|
||||
}, [widget.query]);
|
||||
|
||||
const cursorSyncMode = useMemo(() => {
|
||||
if (panelMode !== PanelMode.DASHBOARD_VIEW) {
|
||||
return DashboardCursorSync.None;
|
||||
}
|
||||
return syncMode;
|
||||
}, [syncMode, panelMode]);
|
||||
|
||||
return (
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<TimeSeries
|
||||
key={cursorSyncMode}
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
@@ -124,6 +145,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
groupBy={groupBy}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
syncMode={cursorSyncMode}
|
||||
layoutChildren={layoutChildren}
|
||||
>
|
||||
<ContextMenu
|
||||
|
||||
@@ -67,4 +67,40 @@ describe('AuthCard', () => {
|
||||
|
||||
expect(mockOnCreateServiceAccount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows URL for non-admin (all roles can fetch instance URL)', () => {
|
||||
render(<AuthCard {...defaultProps} isAdmin={false} />);
|
||||
|
||||
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
|
||||
'http://localhost',
|
||||
);
|
||||
});
|
||||
|
||||
describe('isLoadingInstanceUrl', () => {
|
||||
it('shows a skeleton and hides the URL while loading', () => {
|
||||
render(<AuthCard {...defaultProps} isAdmin isLoadingInstanceUrl />);
|
||||
|
||||
expect(screen.queryByTestId('mcp-instance-url')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.ant-skeleton-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the copy button while loading', () => {
|
||||
render(<AuthCard {...defaultProps} isAdmin isLoadingInstanceUrl />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Copy SigNoz instance URL' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the URL and copy button once loading is done', () => {
|
||||
render(<AuthCard {...defaultProps} isAdmin isLoadingInstanceUrl={false} />);
|
||||
|
||||
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
|
||||
'http://localhost',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Skeleton } from 'antd';
|
||||
import { Badge, Button } from '@signozhq/ui';
|
||||
import { Info, KeyRound } from '@signozhq/icons';
|
||||
import CopyIconButton from '../CopyIconButton';
|
||||
@@ -7,6 +8,7 @@ import './AuthCard.styles.scss';
|
||||
interface AuthCardProps {
|
||||
isAdmin: boolean;
|
||||
instanceUrl: string;
|
||||
isLoadingInstanceUrl?: boolean;
|
||||
onCopyInstanceUrl: () => void;
|
||||
onCreateServiceAccount: () => void;
|
||||
}
|
||||
@@ -14,6 +16,7 @@ interface AuthCardProps {
|
||||
function AuthCard({
|
||||
isAdmin,
|
||||
instanceUrl,
|
||||
isLoadingInstanceUrl = false,
|
||||
onCopyInstanceUrl,
|
||||
onCreateServiceAccount,
|
||||
}: AuthCardProps): JSX.Element {
|
||||
@@ -32,13 +35,18 @@ function AuthCard({
|
||||
|
||||
<div className="mcp-auth-card__field">
|
||||
<span className="mcp-auth-card__field-label">SigNoz Instance URL</span>
|
||||
<div className="mcp-auth-card__endpoint-value">
|
||||
<span data-testid="mcp-instance-url">{instanceUrl}</span>
|
||||
<CopyIconButton
|
||||
ariaLabel="Copy SigNoz instance URL"
|
||||
onCopy={onCopyInstanceUrl}
|
||||
/>
|
||||
</div>
|
||||
{isLoadingInstanceUrl ? (
|
||||
<Skeleton.Input active size="small" />
|
||||
) : (
|
||||
<div className="mcp-auth-card__endpoint-value">
|
||||
<span data-testid="mcp-instance-url">{instanceUrl}</span>
|
||||
<CopyIconButton
|
||||
ariaLabel="Copy SigNoz instance URL"
|
||||
onCopy={onCopyInstanceUrl}
|
||||
disabled={isLoadingInstanceUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mcp-auth-card__field">
|
||||
|
||||
@@ -6,6 +6,8 @@ const mockLogEvent = jest.fn();
|
||||
const mockCopyToClipboard = jest.fn();
|
||||
const mockHistoryPush = jest.fn();
|
||||
const mockUseGetGlobalConfig = jest.fn();
|
||||
const mockUseGetHosts = jest.fn();
|
||||
const mockUseGetTenantLicense = jest.fn();
|
||||
const mockToastSuccess = jest.fn();
|
||||
const mockToastWarning = jest.fn();
|
||||
|
||||
@@ -19,6 +21,14 @@ jest.mock('api/generated/services/global', () => ({
|
||||
mockUseGetGlobalConfig(...args),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/zeus', () => ({
|
||||
useGetHosts: (...args: unknown[]): unknown => mockUseGetHosts(...args),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: (): unknown => mockUseGetTenantLicense(),
|
||||
}));
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
__esModule: true,
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard],
|
||||
@@ -47,6 +57,23 @@ jest.mock('utils/basePath', () => ({
|
||||
}));
|
||||
|
||||
const MCP_URL = 'https://mcp.us.signoz.cloud/mcp';
|
||||
const CUSTOM_HOST_URL = 'https://myteam.signoz.cloud';
|
||||
const DEFAULT_HOST_URL = 'https://default.signoz.cloud';
|
||||
|
||||
function setupLicense({
|
||||
isCloudUser = true,
|
||||
isEnterpriseSelfHostedUser = false,
|
||||
}: {
|
||||
isCloudUser?: boolean;
|
||||
isEnterpriseSelfHostedUser?: boolean;
|
||||
} = {}): void {
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isCommunityUser: !isCloudUser && !isEnterpriseSelfHostedUser,
|
||||
isCommunityEnterpriseUser: false,
|
||||
});
|
||||
}
|
||||
|
||||
function setupGlobalConfig({ mcpUrl }: { mcpUrl: string | null }): void {
|
||||
mockUseGetGlobalConfig.mockReturnValue({
|
||||
@@ -55,7 +82,29 @@ function setupGlobalConfig({ mcpUrl }: { mcpUrl: string | null }): void {
|
||||
});
|
||||
}
|
||||
|
||||
function setupHosts({
|
||||
hosts = [],
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
}: {
|
||||
hosts?: { name?: string; url?: string; is_default?: boolean }[];
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
} = {}): void {
|
||||
mockUseGetHosts.mockReturnValue({
|
||||
data: isLoading || isError ? undefined : { data: { hosts } },
|
||||
isLoading,
|
||||
isError,
|
||||
});
|
||||
}
|
||||
|
||||
describe('MCPServerSettings', () => {
|
||||
beforeEach(() => {
|
||||
// Default: cloud user, hosts loaded but empty → instanceUrl falls back to getBaseUrl()
|
||||
setupLicense();
|
||||
setupHosts();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
@@ -158,4 +207,145 @@ describe('MCPServerSettings', () => {
|
||||
'Instance URL copied to clipboard',
|
||||
);
|
||||
});
|
||||
|
||||
describe('instance URL resolution', () => {
|
||||
it('uses the active custom host URL when available', async () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupHosts({
|
||||
hosts: [
|
||||
{ name: 'default', url: DEFAULT_HOST_URL, is_default: true },
|
||||
{ name: 'myteam', url: CUSTOM_HOST_URL, is_default: false },
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MCPServerSettings />);
|
||||
|
||||
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
|
||||
CUSTOM_HOST_URL,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
|
||||
);
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(CUSTOM_HOST_URL);
|
||||
});
|
||||
|
||||
it('falls back to the default host URL when no custom host exists', async () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupHosts({
|
||||
hosts: [{ name: 'default', url: DEFAULT_HOST_URL, is_default: true }],
|
||||
});
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MCPServerSettings />);
|
||||
|
||||
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
|
||||
DEFAULT_HOST_URL,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
|
||||
);
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(DEFAULT_HOST_URL);
|
||||
});
|
||||
|
||||
it('falls back to browser URL when hosts request errors', async () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupHosts({ isError: true });
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MCPServerSettings />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
|
||||
);
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('http://localhost');
|
||||
});
|
||||
|
||||
it('shows URL skeleton while hosts are loading', () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupHosts({ isLoading: true });
|
||||
|
||||
render(<MCPServerSettings />);
|
||||
|
||||
expect(screen.queryByTestId('mcp-instance-url')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.ant-skeleton-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not copy while hosts are still loading', async () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupHosts({ isLoading: true });
|
||||
userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MCPServerSettings />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Copy SigNoz instance URL' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables the hosts query for non-cloud deployments', () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupLicense({ isCloudUser: false, isEnterpriseSelfHostedUser: true });
|
||||
|
||||
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
|
||||
|
||||
const callOptions = mockUseGetHosts.mock.calls[0]?.[0];
|
||||
expect(callOptions?.query?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('uses browser URL immediately for enterprise self-hosted (no skeleton)', async () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupLicense({ isCloudUser: false, isEnterpriseSelfHostedUser: true });
|
||||
setupHosts({ isLoading: false });
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
|
||||
|
||||
expect(
|
||||
document.querySelector('.ant-skeleton-input'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
|
||||
'http://localhost',
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
|
||||
);
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('http://localhost');
|
||||
});
|
||||
|
||||
it('enables the hosts query for all cloud users including viewers', () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupLicense({ isCloudUser: true });
|
||||
|
||||
render(<MCPServerSettings />, undefined, { role: 'VIEWER' });
|
||||
|
||||
const callOptions = mockUseGetHosts.mock.calls[0]?.[0];
|
||||
expect(callOptions?.query?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('shows instance URL for cloud viewer', () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupLicense({ isCloudUser: true });
|
||||
setupHosts({
|
||||
hosts: [{ name: 'default', url: DEFAULT_HOST_URL, is_default: true }],
|
||||
});
|
||||
|
||||
render(<MCPServerSettings />, undefined, { role: 'VIEWER' });
|
||||
|
||||
expect(
|
||||
document.querySelector('.ant-skeleton-input'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
|
||||
DEFAULT_HOST_URL,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { useGetGlobalConfig } from 'api/generated/services/global';
|
||||
import { useGetHosts } from 'api/generated/services/zeus';
|
||||
import history from 'lib/history';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
@@ -34,7 +36,23 @@ function MCPServerSettings(): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const instanceUrl = getBaseUrl();
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
|
||||
const {
|
||||
data: hostsData,
|
||||
isLoading: isLoadingHosts,
|
||||
isError: isHostsError,
|
||||
} = useGetHosts({ query: { enabled: isCloudUser } });
|
||||
|
||||
const instanceUrl = useMemo(() => {
|
||||
if (isLoadingHosts || isHostsError || !hostsData) {
|
||||
return getBaseUrl();
|
||||
}
|
||||
const hosts = hostsData.data?.hosts ?? [];
|
||||
const activeHost =
|
||||
hosts.find((h) => !h.is_default) ?? hosts.find((h) => h.is_default);
|
||||
return activeHost?.url ?? getBaseUrl();
|
||||
}, [hostsData, isLoadingHosts, isHostsError]);
|
||||
|
||||
const { data: globalConfig, isLoading: isConfigLoading } =
|
||||
useGetGlobalConfig();
|
||||
@@ -70,10 +88,13 @@ function MCPServerSettings(): JSX.Element {
|
||||
}, []);
|
||||
|
||||
const handleCopyInstanceUrl = useCallback(() => {
|
||||
if (isLoadingHosts) {
|
||||
return;
|
||||
}
|
||||
copyToClipboard(instanceUrl);
|
||||
toast.success('Instance URL copied to clipboard');
|
||||
void logEvent(ANALYTICS.INSTANCE_URL_COPIED, {});
|
||||
}, [copyToClipboard, instanceUrl]);
|
||||
}, [copyToClipboard, instanceUrl, isLoadingHosts]);
|
||||
|
||||
const handleDocsLinkClick = useCallback((target: string) => {
|
||||
void logEvent(ANALYTICS.DOCS_LINK_CLICKED, { target });
|
||||
@@ -132,6 +153,7 @@ function MCPServerSettings(): JSX.Element {
|
||||
<AuthCard
|
||||
isAdmin={isAdmin}
|
||||
instanceUrl={instanceUrl}
|
||||
isLoadingInstanceUrl={isLoadingHosts}
|
||||
onCopyInstanceUrl={handleCopyInstanceUrl}
|
||||
onCreateServiceAccount={handleCreateServiceAccount}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { useDashboardCursorSyncMode } from '../useDashboardCursorSyncMode';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.DASHBOARD_PREFERENCES;
|
||||
|
||||
describe('useDashboardCursorSyncMode', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('defaults to Crosshair when no value is stored', () => {
|
||||
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
|
||||
it('reads the stored cursor sync mode for the dashboard', () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('persists the cursor sync mode under the cursorSyncMode key', () => {
|
||||
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default when dashboardId is undefined and the setter is a no-op', () => {
|
||||
const { result } = renderHook(() => useDashboardCursorSyncMode(undefined));
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { useDashboardPreference } from '../useDashboardPreference';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.DASHBOARD_PREFERENCES;
|
||||
const DEFAULT_MODE = DashboardCursorSync.Crosshair;
|
||||
|
||||
const seedStore = (store: Record<string, unknown>): void => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
};
|
||||
|
||||
const readStore = (): Record<string, unknown> => {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
};
|
||||
|
||||
describe('useDashboardPreference', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('returns the default value when no preference is stored', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('returns the default value when dashboardId is undefined', () => {
|
||||
seedStore({ 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('returns the stored value for the given dashboardId', () => {
|
||||
seedStore({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('persists the new value to localStorage when the setter is called', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(readStore()).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write to localStorage when dashboardId is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('keeps multiple hook instances in sync after a write', () => {
|
||||
const { result: writer } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
const { result: reader } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
writer.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(writer.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
expect(reader.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('isolates preferences across different dashboardIds', () => {
|
||||
const { result: dashOne } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
const { result: dashTwo } = renderHook(() =>
|
||||
useDashboardPreference('dash-2', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
dashOne.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(dashOne.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(dashTwo.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('does not overwrite preferences for other dashboards when writing', () => {
|
||||
seedStore({
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(readStore()).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default value when localStorage contains malformed JSON', () => {
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
localStorage.setItem(STORAGE_KEY, '{not-json');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns the default value when the stored payload is not an object', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('a-string'));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('reacts to a native storage event from another tab', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
|
||||
act(() => {
|
||||
seedStore({ 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } });
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: STORAGE_KEY }));
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('ignores storage events for unrelated keys', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
seedStore({ 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } });
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'SOME_OTHER_KEY' }));
|
||||
});
|
||||
|
||||
// No notify => snapshot unchanged for the existing subscriber.
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
});
|
||||
15
frontend/src/hooks/dashboard/useDashboardCursorSyncMode.ts
Normal file
15
frontend/src/hooks/dashboard/useDashboardCursorSyncMode.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { useDashboardPreference } from './useDashboardPreference';
|
||||
|
||||
const DEFAULT_CURSOR_SYNC_MODE = DashboardCursorSync.Crosshair;
|
||||
|
||||
export function useDashboardCursorSyncMode(
|
||||
dashboardId: string | undefined,
|
||||
): [DashboardCursorSync, (value: DashboardCursorSync) => void] {
|
||||
return useDashboardPreference(
|
||||
dashboardId,
|
||||
'cursorSyncMode',
|
||||
DEFAULT_CURSOR_SYNC_MODE,
|
||||
);
|
||||
}
|
||||
107
frontend/src/hooks/dashboard/useDashboardPreference.ts
Normal file
107
frontend/src/hooks/dashboard/useDashboardPreference.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
// Per-dashboard preferences persisted in localStorage. Add new preference
|
||||
// fields here as they are introduced.
|
||||
export type DashboardPreferences = {
|
||||
cursorSyncMode?: DashboardCursorSync;
|
||||
};
|
||||
|
||||
type DashboardPreferencesStore = Record<string, DashboardPreferences>;
|
||||
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
const subscribe = (callback: () => void): (() => void) => {
|
||||
subscribers.add(callback);
|
||||
return (): void => {
|
||||
subscribers.delete(callback);
|
||||
};
|
||||
};
|
||||
|
||||
const notify = (): void => {
|
||||
subscribers.forEach((cb) => cb());
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === LOCALSTORAGE.DASHBOARD_PREFERENCES) {
|
||||
notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const readStore = (): DashboardPreferencesStore => {
|
||||
try {
|
||||
const raw = getLocalStorageApi(LOCALSTORAGE.DASHBOARD_PREFERENCES);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return parsed as DashboardPreferencesStore;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Error reading localStorage key "${LOCALSTORAGE.DASHBOARD_PREFERENCES}":`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const writeStore = (store: DashboardPreferencesStore): void => {
|
||||
try {
|
||||
setLocalStorageApi(LOCALSTORAGE.DASHBOARD_PREFERENCES, JSON.stringify(store));
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Error writing localStorage key "${LOCALSTORAGE.DASHBOARD_PREFERENCES}":`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const readPreference = <K extends keyof DashboardPreferences>(
|
||||
dashboardId: string | undefined,
|
||||
key: K,
|
||||
): DashboardPreferences[K] | undefined => {
|
||||
if (!dashboardId) {
|
||||
return undefined;
|
||||
}
|
||||
return readStore()[dashboardId]?.[key];
|
||||
};
|
||||
|
||||
export function useDashboardPreference<K extends keyof DashboardPreferences>(
|
||||
dashboardId: string | undefined,
|
||||
key: K,
|
||||
defaultValue: NonNullable<DashboardPreferences[K]>,
|
||||
): [
|
||||
NonNullable<DashboardPreferences[K]>,
|
||||
(value: NonNullable<DashboardPreferences[K]>) => void,
|
||||
] {
|
||||
type Value = NonNullable<DashboardPreferences[K]>;
|
||||
|
||||
const getSnapshot = useCallback(
|
||||
(): Value =>
|
||||
(readPreference(dashboardId, key) as Value | undefined) ?? defaultValue,
|
||||
[dashboardId, key, defaultValue],
|
||||
);
|
||||
|
||||
const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
const updateValue = useCallback(
|
||||
(next: Value) => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
const store = readStore();
|
||||
store[dashboardId] = { ...store[dashboardId], [key]: next };
|
||||
writeStore(store);
|
||||
notify();
|
||||
},
|
||||
[dashboardId, key],
|
||||
);
|
||||
|
||||
return [value, updateValue];
|
||||
}
|
||||
@@ -30,8 +30,11 @@ function getCommonGroupByKeys(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the 1-based indexes of every series whose metric matches
|
||||
* sourceMetric on all commonKeys.
|
||||
* Returns the 1-based indexes of every visible series whose metric matches
|
||||
* sourceMetric on all commonKeys. Hidden series (show === false) are
|
||||
* excluded — a hidden match contributes nothing to the receiver tooltip,
|
||||
* so treating it as "no match" lets the empty-array path suppress the
|
||||
* tooltip entirely instead of rendering an empty shell.
|
||||
*/
|
||||
function findMatchingSeriesIndexes(
|
||||
series: uPlot.Series[],
|
||||
@@ -39,7 +42,7 @@ function findMatchingSeriesIndexes(
|
||||
commonKeys: string[],
|
||||
): number[] {
|
||||
return series.reduce<number[]>((acc, s, i) => {
|
||||
if (i === 0) {
|
||||
if (i === 0 || s.show === false) {
|
||||
return acc;
|
||||
}
|
||||
const metric = (s as ExtendedSeries).metric;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { ExtendedSeries } from '../../config/types';
|
||||
import { syncCursorRegistry } from '../TooltipPlugin/syncCursorRegistry';
|
||||
import { createSyncDisplayHook } from '../TooltipPlugin/syncDisplayHook';
|
||||
import type {
|
||||
TooltipControllerState,
|
||||
TooltipSyncMetadata,
|
||||
} from '../TooltipPlugin/types';
|
||||
|
||||
const SYNC_KEY = 'test-sync';
|
||||
|
||||
function makeController(): TooltipControllerState {
|
||||
return {
|
||||
plot: null,
|
||||
hoverActive: false,
|
||||
isAnySeriesActive: false,
|
||||
pinned: false,
|
||||
clickData: null,
|
||||
style: {},
|
||||
horizontalOffset: 0,
|
||||
verticalOffset: 0,
|
||||
seriesIndexes: [],
|
||||
focusedSeriesIndex: null,
|
||||
syncedSeriesIndexes: null,
|
||||
cursorDrivenBySync: false,
|
||||
plotWithinViewport: true,
|
||||
windowWidth: 1024,
|
||||
windowHeight: 768,
|
||||
pendingPinnedUpdate: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakePlot(
|
||||
series: ExtendedSeries[],
|
||||
cursorEvent: Record<string, unknown> | null = null,
|
||||
): uPlot {
|
||||
const root = document.createElement('div');
|
||||
const yCrosshair = document.createElement('div');
|
||||
yCrosshair.className = 'u-cursor-y';
|
||||
root.appendChild(yCrosshair);
|
||||
|
||||
return {
|
||||
root,
|
||||
series,
|
||||
cursor: { event: cursorEvent, left: 50 },
|
||||
setSeries: jest.fn(),
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
const SERVICE_NAME_KEY: BaseAutocompleteData = {
|
||||
key: 'service.name',
|
||||
type: 'tag',
|
||||
};
|
||||
|
||||
const groupByService: TooltipSyncMetadata = {
|
||||
groupBy: [SERVICE_NAME_KEY],
|
||||
};
|
||||
|
||||
function seedSourcePanel(activeMetric: Record<string, string>): void {
|
||||
syncCursorRegistry.setMetadata(SYNC_KEY, groupByService);
|
||||
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, activeMetric);
|
||||
}
|
||||
|
||||
function makeReceiverSeries(
|
||||
entries: { name: string; show?: boolean }[],
|
||||
): ExtendedSeries[] {
|
||||
return [
|
||||
{} as ExtendedSeries,
|
||||
...entries.map(
|
||||
(e) =>
|
||||
({
|
||||
show: e.show ?? true,
|
||||
metric: { 'service.name': e.name },
|
||||
}) as unknown as ExtendedSeries,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
describe('createSyncDisplayHook (receiver-side filtering)', () => {
|
||||
beforeEach(() => {
|
||||
syncCursorRegistry.setMetadata(SYNC_KEY, undefined);
|
||||
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, null);
|
||||
});
|
||||
|
||||
it('returns indexes of visible matching series only', () => {
|
||||
seedSourcePanel({ 'service.name': 'flagd' });
|
||||
|
||||
const series = makeReceiverSeries([
|
||||
{ name: 'flagd', show: true },
|
||||
{ name: 'frontend', show: true },
|
||||
{ name: 'flagd', show: true },
|
||||
]);
|
||||
const plot = makeFakePlot(series, null);
|
||||
const controller = makeController();
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('treats all matching series being hidden as no match → empty array', () => {
|
||||
seedSourcePanel({ 'service.name': 'frontendproxy' });
|
||||
|
||||
const series = makeReceiverSeries([
|
||||
{ name: 'flagd', show: true },
|
||||
{ name: 'frontendproxy', show: false },
|
||||
]);
|
||||
const plot = makeFakePlot(series, null);
|
||||
const controller = makeController();
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(null, { focus: false });
|
||||
});
|
||||
|
||||
it('excludes hidden series and keeps the visible matches', () => {
|
||||
seedSourcePanel({ 'service.name': 'flagd' });
|
||||
|
||||
const series = makeReceiverSeries([
|
||||
{ name: 'flagd', show: false },
|
||||
{ name: 'frontend', show: true },
|
||||
{ name: 'flagd', show: true },
|
||||
]);
|
||||
const plot = makeFakePlot(series, null);
|
||||
const controller = makeController();
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([3]);
|
||||
// Focuses the first visible match, not the hidden one at index 1.
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(3, { focus: true });
|
||||
});
|
||||
|
||||
it('returns null (no filtering) when the hook runs on the source panel', () => {
|
||||
const series = makeReceiverSeries([{ name: 'flagd', show: true }]);
|
||||
// cursor.event != null marks this invocation as the source panel.
|
||||
const plot = makeFakePlot(series, { type: 'mousemove' });
|
||||
const controller = makeController();
|
||||
controller.focusedSeriesIndex = 1;
|
||||
(series[1] as ExtendedSeries).metric = { 'service.name': 'flagd' };
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toBeNull();
|
||||
expect(syncCursorRegistry.getActiveSeriesMetric(SYNC_KEY)).toStrictEqual({
|
||||
'service.name': 'flagd',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ function SettingsPage(): JSX.Element {
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const isEditor = user.role === USER_ROLES.EDITOR;
|
||||
const isViewer = user.role === USER_ROLES.VIEWER;
|
||||
|
||||
const isWorkspaceBlocked = trialInfo?.workSpaceBlock || false;
|
||||
|
||||
@@ -102,6 +103,13 @@ function SettingsPage(): JSX.Element {
|
||||
: item.isEnabled,
|
||||
}));
|
||||
}
|
||||
|
||||
if (isViewer) {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled: item.key === ROUTES.MCP_SERVER ? true : item.isEnabled,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (isEnterpriseSelfHostedUser) {
|
||||
@@ -134,6 +142,13 @@ function SettingsPage(): JSX.Element {
|
||||
: item.isEnabled,
|
||||
}));
|
||||
}
|
||||
|
||||
if (isViewer) {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled: item.key === ROUTES.MCP_SERVER ? true : item.isEnabled,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCloudUser && !isEnterpriseSelfHostedUser) {
|
||||
@@ -166,6 +181,7 @@ function SettingsPage(): JSX.Element {
|
||||
}, [
|
||||
isAdmin,
|
||||
isEditor,
|
||||
isViewer,
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isFetchingActiveLicense,
|
||||
|
||||
@@ -82,12 +82,13 @@ describe('SettingsPage nav sections', () => {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['billing', 'roles', 'mcp-server'])(
|
||||
'does not render "%s" element',
|
||||
(id) => {
|
||||
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
it.each(['billing', 'roles'])('does not render "%s" element', (id) => {
|
||||
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "mcp-server" element', () => {
|
||||
expect(screen.getByTestId('mcp-server')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Self-hosted Admin', () => {
|
||||
|
||||
@@ -27,7 +27,7 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authZ.AdminAccess(provider.zeusHandler.GetHosts), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authZ.ViewAccess(provider.zeusHandler.GetHosts), handler.OpenAPIDef{
|
||||
ID: "GetHosts",
|
||||
Tags: []string{"zeus"},
|
||||
Summary: "Get host info from Zeus.",
|
||||
@@ -39,7 +39,7 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package impllmpricingrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const LLMCostFeatureType agentConf.AgentFeatureType = "llm_pricing"
|
||||
|
||||
// LLMCostFeature implements agentConf.AgentFeature. It reads pricing rules
|
||||
// from the module and generates the signozllmpricing processor config for
|
||||
// deployment to OTel collectors via OpAMP.
|
||||
type LLMCostFeature struct {
|
||||
module llmpricingrule.Module
|
||||
}
|
||||
|
||||
func NewLLMCostFeature(module llmpricingrule.Module) *LLMCostFeature {
|
||||
return &LLMCostFeature{module: module}
|
||||
}
|
||||
|
||||
func (f *LLMCostFeature) AgentFeatureType() agentConf.AgentFeatureType {
|
||||
return LLMCostFeatureType
|
||||
}
|
||||
|
||||
func (f *LLMCostFeature) RecommendAgentConfig(
|
||||
orgId valuer.UUID,
|
||||
currentConfYaml []byte,
|
||||
configVersion *opamptypes.AgentConfigVersion,
|
||||
) ([]byte, string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
rules, err := f.getEnabledRules(ctx, orgId)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
updatedConf, err := generateCollectorConfigWithLLMPricingProcessor(currentConfYaml, rules)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
serialized, err := json.Marshal(rules)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return updatedConf, string(serialized), nil
|
||||
}
|
||||
|
||||
// getEnabledRules fetches all enabled pricing rules for the given org.
|
||||
func (f *LLMCostFeature) getEnabledRules(ctx context.Context, orgId valuer.UUID) ([]*llmpricingruletypes.LLMPricingRule, error) {
|
||||
if f.module == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rules, _, err := f.module.List(ctx, orgId, 0, 10000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
enabled := make([]*llmpricingruletypes.LLMPricingRule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
if r.Enabled {
|
||||
enabled = append(enabled, r)
|
||||
}
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package impllmpricingrule
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const processorName = "signozllmpricing"
|
||||
|
||||
// buildProcessorConfig converts pricing rules into the signozllmpricing processor config.
|
||||
func buildProcessorConfig(rules []*llmpricingruletypes.LLMPricingRule) *llmpricingruletypes.LLMPricingRuleProcessorConfig {
|
||||
pricingRules := make([]llmpricingruletypes.LLMPricingRuleProcessor, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
var cache llmpricingruletypes.LLMPricingRuleProcessorCache
|
||||
if r.Pricing.Cache != nil {
|
||||
cache = llmpricingruletypes.LLMPricingRuleProcessorCache{
|
||||
Mode: r.Pricing.Cache.Mode.StringValue(),
|
||||
Read: r.Pricing.Cache.Read,
|
||||
Write: r.Pricing.Cache.Write,
|
||||
}
|
||||
}
|
||||
pricingRules = append(pricingRules, llmpricingruletypes.LLMPricingRuleProcessor{
|
||||
Name: r.Model,
|
||||
Pattern: r.ModelPattern,
|
||||
Cache: cache,
|
||||
In: r.Pricing.Input,
|
||||
Out: r.Pricing.Output,
|
||||
})
|
||||
}
|
||||
|
||||
return &llmpricingruletypes.LLMPricingRuleProcessorConfig{
|
||||
Attrs: llmpricingruletypes.LLMPricingRuleProcessorAttrs{
|
||||
Model: "gen_ai.request.model",
|
||||
In: "gen_ai.usage.input_tokens",
|
||||
Out: "gen_ai.usage.output_tokens",
|
||||
CacheRead: "gen_ai.usage.cache_read.input_tokens",
|
||||
CacheWrite: "gen_ai.usage.cache_creation.input_tokens",
|
||||
},
|
||||
DefaultPricing: llmpricingruletypes.LLMPricingRuleProcessorDefaultPricing{
|
||||
Unit: "per_million_tokens",
|
||||
Rules: pricingRules,
|
||||
},
|
||||
OutputAttrs: llmpricingruletypes.LLMPricingRuleProcessorOutputAttrs{
|
||||
In: "_signoz.gen_ai.cost_input",
|
||||
Out: "_signoz.gen_ai.cost_output",
|
||||
CacheRead: "_signoz.gen_ai.cost_cache_read",
|
||||
CacheWrite: "_signoz.gen_ai.cost_cache_write",
|
||||
Total: "_signoz.gen_ai.total_cost",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// generateCollectorConfigWithLLMPricingProcessor injects (or replaces) the signozllmpricing
|
||||
// processor block in the collector YAML with one built from the given rules.
|
||||
// Pipeline wiring is handled by the collector's baseline config, not here.
|
||||
func generateCollectorConfigWithLLMPricingProcessor(
|
||||
currentConfYaml []byte,
|
||||
rules []*llmpricingruletypes.LLMPricingRule,
|
||||
) ([]byte, error) {
|
||||
// Empty input: nothing to inject into. Pass through unchanged so we don't
|
||||
// turn it into "null\n" or fail on yaml.v3's EOF.
|
||||
if len(bytes.TrimSpace(currentConfYaml)) == 0 {
|
||||
return currentConfYaml, nil
|
||||
}
|
||||
|
||||
var collectorConf map[string]any
|
||||
if err := yaml.Unmarshal(currentConfYaml, &collectorConf); err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, llmpricingruletypes.ErrCodeInvalidCollectorConfig, "failed to unmarshal collector config")
|
||||
}
|
||||
// rare but don't do anything in this case, also means it's just comments
|
||||
if collectorConf == nil {
|
||||
return currentConfYaml, nil
|
||||
}
|
||||
|
||||
processors := map[string]any{}
|
||||
if existing, ok := collectorConf["processors"]; ok && existing != nil {
|
||||
p, ok := existing.(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, llmpricingruletypes.ErrCodeInvalidCollectorConfig, "collector config 'processors' must be a mapping, got %T", existing)
|
||||
}
|
||||
processors = p
|
||||
}
|
||||
|
||||
processors[processorName] = buildProcessorConfig(rules)
|
||||
collectorConf["processors"] = processors
|
||||
|
||||
out, err := yaml.Marshal(collectorConf)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, llmpricingruletypes.ErrCodeBuildPricingProcessorConf, "failed to marshal llm pricing processor config")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package impllmpricingrule
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// assertYAMLEqualToFile decodes both sides into any and compares structurally,
|
||||
// so map key ordering is irrelevant.
|
||||
func assertYAMLEqualToFile(t *testing.T, name string, actual []byte) {
|
||||
t.Helper()
|
||||
expected, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
require.NoError(t, err)
|
||||
|
||||
var e, a any
|
||||
require.NoError(t, yaml.Unmarshal(expected, &e))
|
||||
require.NoError(t, yaml.Unmarshal(actual, &a))
|
||||
assert.Equal(t, e, a)
|
||||
}
|
||||
|
||||
func makePricingRule(model string, patterns []string, cacheMode llmpricingruletypes.LLMPricingRuleCacheMode, costIn, costOut, cacheRead, cacheWrite float64) *llmpricingruletypes.LLMPricingRule {
|
||||
return &llmpricingruletypes.LLMPricingRule{
|
||||
Model: model,
|
||||
ModelPattern: llmpricingruletypes.StringSlice(patterns),
|
||||
Unit: llmpricingruletypes.UnitPerMillionTokens,
|
||||
Pricing: llmpricingruletypes.LLMRulePricing{
|
||||
Input: costIn,
|
||||
Output: costOut,
|
||||
Cache: &llmpricingruletypes.LLMPricingCacheCosts{
|
||||
Mode: cacheMode,
|
||||
Read: cacheRead,
|
||||
Write: cacheWrite,
|
||||
},
|
||||
},
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCollectorConfigWithLLMPricingProcessor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rules []*llmpricingruletypes.LLMPricingRule
|
||||
expectedFile string
|
||||
}{
|
||||
{
|
||||
name: "with_rule",
|
||||
rules: []*llmpricingruletypes.LLMPricingRule{
|
||||
makePricingRule("gpt-4o", []string{"gpt-4o*"}, llmpricingruletypes.LLMPricingRuleCacheModeSubtract, 5.0, 15.0, 2.5, 0),
|
||||
},
|
||||
expectedFile: "collector_with_rule.yaml",
|
||||
},
|
||||
// We deploy the processor even with zero rules so rules can be added
|
||||
// later (by a user or by Zeus) without any config-shape change.
|
||||
// Pipeline wiring is handled by the collector's baseline config.
|
||||
{
|
||||
name: "no_rules",
|
||||
rules: nil,
|
||||
expectedFile: "collector_no_rules.yaml",
|
||||
},
|
||||
}
|
||||
|
||||
input, err := os.ReadFile(filepath.Join("testdata", "collector_baseline.yaml"))
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := generateCollectorConfigWithLLMPricingProcessor(input, tc.rules)
|
||||
require.NoError(t, err)
|
||||
assertYAMLEqualToFile(t, tc.expectedFile, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCollectorConfig_EmptyInputPassthrough(t *testing.T) {
|
||||
// yaml.v3 errors on empty/whitespace input; the generator passes such
|
||||
// input through unchanged instead.
|
||||
rules := []*llmpricingruletypes.LLMPricingRule{
|
||||
makePricingRule("gpt-4o", []string{"gpt-4o*"}, llmpricingruletypes.LLMPricingRuleCacheModeSubtract, 5.0, 15.0, 2.5, 0),
|
||||
}
|
||||
|
||||
for _, in := range [][]byte{nil, []byte(" \n")} {
|
||||
out, err := generateCollectorConfigWithLLMPricingProcessor(in, rules)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, in, out)
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package impllmpricingrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store llmpricingruletypes.Store
|
||||
}
|
||||
|
||||
func NewModule(store llmpricingruletypes.Store) llmpricingrule.Module {
|
||||
return &module{store: store}
|
||||
}
|
||||
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
|
||||
return module.store.List(ctx, orgID, offset, limit)
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error) {
|
||||
return module.store.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
// CreateOrUpdate applies a batch of pricing rule changes:
|
||||
// - ID set → match by id, overwrite fields.
|
||||
// - SourceID set → match by source_id; if found overwrite, else insert.
|
||||
// - neither set → insert a new user-created row (is_override = true).
|
||||
//
|
||||
// When UpdatableLLMPricingRule.IsOverride is nil AND the matched row has
|
||||
// is_override = true, the row is fully preserved — only synced_at is stamped.
|
||||
func (module *module) CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []llmpricingruletypes.UpdatableLLMPricingRule) error {
|
||||
now := time.Now()
|
||||
|
||||
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
for _, u := range rules {
|
||||
existing, err := module.findExisting(ctx, orgID, u)
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
if err := module.store.Create(ctx, llmpricingruletypes.NewLLMPricingRuleFromUpdatable(u, orgID, userEmail, now)); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
existing.Update(u, userEmail, now)
|
||||
if err := module.store.Update(ctx, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) Delete(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
if err := module.store.Delete(ctx, orgID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findExisting returns the row matching the updatable's ID or SourceID.
|
||||
// Returns a TypeNotFound error when neither matches; the caller treats that
|
||||
// as "insert new".
|
||||
func (module *module) findExisting(ctx context.Context, orgID valuer.UUID, u llmpricingruletypes.UpdatableLLMPricingRule) (*llmpricingruletypes.LLMPricingRule, error) {
|
||||
switch {
|
||||
case u.ID != nil:
|
||||
return module.store.Get(ctx, orgID, *u.ID)
|
||||
case u.SourceID != nil:
|
||||
return module.store.GetBySourceID(ctx, orgID, *u.SourceID)
|
||||
default:
|
||||
return nil, errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "rule has neither id nor sourceId")
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package impllmpricingrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) llmpricingruletypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
|
||||
rules := make([]*llmpricingruletypes.LLMPricingRule, 0)
|
||||
|
||||
count, err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&rules).
|
||||
Where("org_id = ?", orgID).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
ScanAndCount(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rules, count, nil
|
||||
}
|
||||
|
||||
func (store *store) Get(ctx context.Context, orgID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error) {
|
||||
rule := new(llmpricingruletypes.LLMPricingRule)
|
||||
|
||||
err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(rule).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, llmpricingruletypes.ErrCodePricingRuleNotFound, "pricing rule %s not found", id)
|
||||
}
|
||||
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (store *store) GetBySourceID(ctx context.Context, orgID, sourceID valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error) {
|
||||
rule := new(llmpricingruletypes.LLMPricingRule)
|
||||
|
||||
err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(rule).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("source_id = ?", sourceID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, llmpricingruletypes.ErrCodePricingRuleNotFound, "pricing rule with source_id %s not found", sourceID)
|
||||
}
|
||||
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, rule *llmpricingruletypes.LLMPricingRule) error {
|
||||
_, err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(rule).
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (store *store) Update(ctx context.Context, rule *llmpricingruletypes.LLMPricingRule) error {
|
||||
res, err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(rule).
|
||||
Where("org_id = ?", rule.OrgID).
|
||||
Where("id = ?", rule.ID).
|
||||
ExcludeColumn("id", "org_id", "created_at", "created_by").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "pricing rule %s not found", rule.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Delete(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
res, err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*llmpricingruletypes.LLMPricingRule)(nil)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "pricing rule %s not found", id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
processors:
|
||||
signozllmpricing:
|
||||
attrs:
|
||||
model: gen_ai.request.model
|
||||
in: gen_ai.usage.input_tokens
|
||||
out: gen_ai.usage.output_tokens
|
||||
cache_read: gen_ai.usage.cache_read.input_tokens
|
||||
cache_write: gen_ai.usage.cache_creation.input_tokens
|
||||
default_pricing:
|
||||
unit: per_million_tokens
|
||||
rules: []
|
||||
output_attrs:
|
||||
in: _signoz.gen_ai.cost_input
|
||||
out: _signoz.gen_ai.cost_output
|
||||
cache_read: _signoz.gen_ai.cost_cache_read
|
||||
cache_write: _signoz.gen_ai.cost_cache_write
|
||||
total: _signoz.gen_ai.total_cost
|
||||
batch: {}
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: localhost:4317
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [batch, signozllmpricing]
|
||||
exporters: [otlp]
|
||||
@@ -1,35 +0,0 @@
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: localhost:4317
|
||||
processors:
|
||||
batch: {}
|
||||
signozllmpricing:
|
||||
attrs:
|
||||
model: gen_ai.request.model
|
||||
in: gen_ai.usage.input_tokens
|
||||
out: gen_ai.usage.output_tokens
|
||||
cache_read: gen_ai.usage.cache_read.input_tokens
|
||||
cache_write: gen_ai.usage.cache_creation.input_tokens
|
||||
default_pricing:
|
||||
unit: per_million_tokens
|
||||
rules: []
|
||||
output_attrs:
|
||||
in: _signoz.gen_ai.cost_input
|
||||
out: _signoz.gen_ai.cost_output
|
||||
cache_read: _signoz.gen_ai.cost_cache_read
|
||||
cache_write: _signoz.gen_ai.cost_cache_write
|
||||
total: _signoz.gen_ai.total_cost
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc: null
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
exporters:
|
||||
- otlp
|
||||
processors:
|
||||
- batch
|
||||
- signozllmpricing
|
||||
receivers:
|
||||
- otlp
|
||||
@@ -1,44 +0,0 @@
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: localhost:4317
|
||||
processors:
|
||||
batch: {}
|
||||
signozllmpricing:
|
||||
attrs:
|
||||
model: gen_ai.request.model
|
||||
in: gen_ai.usage.input_tokens
|
||||
out: gen_ai.usage.output_tokens
|
||||
cache_read: gen_ai.usage.cache_read.input_tokens
|
||||
cache_write: gen_ai.usage.cache_creation.input_tokens
|
||||
default_pricing:
|
||||
unit: per_million_tokens
|
||||
rules:
|
||||
- name: gpt-4o
|
||||
pattern:
|
||||
- gpt-4o*
|
||||
cache:
|
||||
mode: subtract
|
||||
read: 2.5
|
||||
write: 0
|
||||
in: 5
|
||||
out: 15
|
||||
output_attrs:
|
||||
in: _signoz.gen_ai.cost_input
|
||||
out: _signoz.gen_ai.cost_output
|
||||
cache_read: _signoz.gen_ai.cost_cache_read
|
||||
cache_write: _signoz.gen_ai.cost_cache_write
|
||||
total: _signoz.gen_ai.total_cost
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc: null
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
exporters:
|
||||
- otlp
|
||||
processors:
|
||||
- batch
|
||||
- signozllmpricing
|
||||
receivers:
|
||||
- otlp
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/cache/memorycache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule/impllmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
@@ -131,14 +130,11 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
|
||||
opAmpModel.Init(signoz.SQLStore, signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter)
|
||||
|
||||
llmCostFeature := impllmpricingrule.NewLLMCostFeature(signoz.Modules.LLMPricingRule)
|
||||
|
||||
agentConfMgr, err := agentConf.Initiate(
|
||||
&agentConf.ManagerOptions{
|
||||
Store: signoz.SQLStore,
|
||||
AgentFeatures: []agentConf.AgentFeature{
|
||||
logParsingPipelineController,
|
||||
llmCostFeature,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -124,6 +124,6 @@ func NewHandlers(
|
||||
AlertmanagerHandler: signozalertmanager.NewHandler(alertmanagerService),
|
||||
TraceDetail: impltracedetail.NewHandler(modules.TraceDetail),
|
||||
RulerHandler: signozruler.NewHandler(rulerService),
|
||||
LLMPricingRuleHandler: impllmpricingrule.NewHandler(modules.LLMPricingRule, providerSettings),
|
||||
LLMPricingRuleHandler: impllmpricingrule.NewHandler(nil, providerSettings),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring/implinframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule/impllmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
@@ -82,7 +80,6 @@ type Modules struct {
|
||||
CloudIntegration cloudintegration.Module
|
||||
RuleStateHistory rulestatehistory.Module
|
||||
TraceDetail tracedetail.Module
|
||||
LLMPricingRule llmpricingrule.Module
|
||||
}
|
||||
|
||||
func NewModules(
|
||||
@@ -136,6 +133,5 @@ func NewModules(
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +195,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewServiceAccountAuthzactory(sqlstore),
|
||||
sqlmigration.NewDropUserDeletedAtFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateAWSAllRegionsFactory(sqlstore),
|
||||
sqlmigration.NewAddLLMPricingRulesFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addLLMPricingRules struct {
|
||||
sqlschema sqlschema.SQLSchema
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddLLMPricingRulesFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_llm_pricing_rule"), func(_ context.Context, _ factory.ProviderSettings, _ Config) (SQLMigration, error) {
|
||||
return &addLLMPricingRules{
|
||||
sqlschema: sqlschema,
|
||||
sqlstore: sqlstore,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addLLMPricingRules) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *addLLMPricingRules) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
sqls := [][]byte{}
|
||||
|
||||
tableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "llm_pricing_rule",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "created_by", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "updated_by", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "source_id", DataType: sqlschema.DataTypeText, Nullable: true},
|
||||
{Name: "model", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "provider", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "model_pattern", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "unit", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "pricing", DataType: sqlschema.DataTypeText, Nullable: false, Default: "'{}'"},
|
||||
{Name: "is_override", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "false"},
|
||||
{Name: "synced_at", DataType: sqlschema.DataTypeTimestamp, Nullable: true},
|
||||
{Name: "enabled", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "true"},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
|
||||
ColumnNames: []sqlschema.ColumnName{"id"},
|
||||
},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("org_id"),
|
||||
ReferencedTableName: sqlschema.TableName("organizations"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
sqls = append(sqls, tableSQLs...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Partial unique index: one Zeus-synced rule per (org, source). User-created
|
||||
// rules carry source_id = NULL and are intentionally excluded from the
|
||||
// constraint (a single org may have many).
|
||||
if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS llm_pricing_rule_org_source_unique ON llm_pricing_rule (org_id, source_id) WHERE source_id IS NOT NULL`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addLLMPricingRules) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -12,10 +12,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodePricingRuleNotFound = errors.MustNewCode("pricing_rule_not_found")
|
||||
ErrCodePricingRuleInvalidInput = errors.MustNewCode("pricing_rule_invalid_input")
|
||||
ErrCodeInvalidCollectorConfig = errors.MustNewCode("invalid_collector_config")
|
||||
ErrCodeBuildPricingProcessorConf = errors.MustNewCode("build_pricing_processor_config")
|
||||
ErrCodePricingRuleNotFound = errors.MustNewCode("pricing_rule_not_found")
|
||||
ErrCodePricingRuleInvalidInput = errors.MustNewCode("pricing_rule_invalid_input")
|
||||
)
|
||||
|
||||
type LLMPricingRuleUnit struct {
|
||||
@@ -185,48 +183,3 @@ func NewGettableLLMPricingRulesFromLLMPricingRules(items []*LLMPricingRule, tota
|
||||
Limit: limit,
|
||||
}
|
||||
}
|
||||
|
||||
func NewLLMPricingRuleFromUpdatable(u UpdatableLLMPricingRule, orgID valuer.UUID, userEmail string, now time.Time) *LLMPricingRule {
|
||||
isOverride := true
|
||||
if u.IsOverride != nil {
|
||||
isOverride = *u.IsOverride
|
||||
} else if u.SourceID != nil {
|
||||
isOverride = false
|
||||
}
|
||||
|
||||
return &LLMPricingRule{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
UserAuditable: types.UserAuditable{CreatedBy: userEmail, UpdatedBy: userEmail},
|
||||
OrgID: orgID,
|
||||
SourceID: u.SourceID,
|
||||
Model: u.Model,
|
||||
Provider: u.Provider,
|
||||
ModelPattern: StringSlice(u.ModelPattern),
|
||||
Unit: u.Unit,
|
||||
Pricing: u.Pricing,
|
||||
IsOverride: isOverride,
|
||||
SyncedAt: &now,
|
||||
Enabled: u.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *LLMPricingRule) Update(u UpdatableLLMPricingRule, userEmail string, now time.Time) {
|
||||
if u.IsOverride == nil && r.IsOverride {
|
||||
r.SyncedAt = &now
|
||||
return
|
||||
}
|
||||
|
||||
r.Model = u.Model
|
||||
r.Provider = u.Provider
|
||||
r.ModelPattern = StringSlice(u.ModelPattern)
|
||||
r.Unit = u.Unit
|
||||
r.Pricing = u.Pricing
|
||||
if u.IsOverride != nil {
|
||||
r.IsOverride = *u.IsOverride
|
||||
}
|
||||
r.Enabled = u.Enabled
|
||||
r.SyncedAt = &now
|
||||
r.UpdatedAt = now
|
||||
r.UpdatedBy = userEmail
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package llmpricingruletypes
|
||||
|
||||
// LLMPricingRuleProcessorConfig is the top-level config for the signozllmpricing
|
||||
// OTel processor that gets deployed to collectors via OpAMP.
|
||||
type LLMPricingRuleProcessorConfig struct {
|
||||
Attrs LLMPricingRuleProcessorAttrs `yaml:"attrs" json:"attrs"`
|
||||
DefaultPricing LLMPricingRuleProcessorDefaultPricing `yaml:"default_pricing" json:"default_pricing"`
|
||||
OutputAttrs LLMPricingRuleProcessorOutputAttrs `yaml:"output_attrs" json:"output_attrs"`
|
||||
}
|
||||
|
||||
// LLMPricingRuleProcessorAttrs maps span attribute names to the processor's input fields.
|
||||
type LLMPricingRuleProcessorAttrs struct {
|
||||
Model string `yaml:"model" json:"model"`
|
||||
In string `yaml:"in" json:"in"`
|
||||
Out string `yaml:"out" json:"out"`
|
||||
CacheRead string `yaml:"cache_read" json:"cache_read"`
|
||||
CacheWrite string `yaml:"cache_write" json:"cache_write"`
|
||||
}
|
||||
|
||||
// LLMPricingRuleProcessorDefaultPricing holds the pricing unit and the list of model-specific rules.
|
||||
type LLMPricingRuleProcessorDefaultPricing struct {
|
||||
Unit string `yaml:"unit" json:"unit"`
|
||||
Rules []LLMPricingRuleProcessor `yaml:"rules" json:"rules"`
|
||||
}
|
||||
|
||||
// LLMPricingRuleProcessor is a single pricing rule inside the processor config.
|
||||
type LLMPricingRuleProcessor struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Pattern []string `yaml:"pattern" json:"pattern"`
|
||||
Cache LLMPricingRuleProcessorCache `yaml:"cache" json:"cache"`
|
||||
In float64 `yaml:"in" json:"in"`
|
||||
Out float64 `yaml:"out" json:"out"`
|
||||
}
|
||||
|
||||
// LLMPricingRuleProcessorCache describes how cached tokens are accounted for.
|
||||
type LLMPricingRuleProcessorCache struct {
|
||||
Mode string `yaml:"mode" json:"mode"`
|
||||
Read float64 `yaml:"read" json:"read"`
|
||||
Write float64 `yaml:"write" json:"write"`
|
||||
}
|
||||
|
||||
// LLMPricingRuleProcessorOutputAttrs maps the processor's computed cost fields to span attribute names.
|
||||
type LLMPricingRuleProcessorOutputAttrs struct {
|
||||
In string `yaml:"in" json:"in"`
|
||||
Out string `yaml:"out" json:"out"`
|
||||
CacheRead string `yaml:"cache_read" json:"cache_read"`
|
||||
CacheWrite string `yaml:"cache_write" json:"cache_write"`
|
||||
Total string `yaml:"total" json:"total"`
|
||||
}
|
||||
@@ -7,11 +7,10 @@ import (
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*LLMPricingRule, int, error)
|
||||
Get(ctx context.Context, orgID, id valuer.UUID) (*LLMPricingRule, error)
|
||||
GetBySourceID(ctx context.Context, orgID, sourceID valuer.UUID) (*LLMPricingRule, error)
|
||||
Create(ctx context.Context, rule *LLMPricingRule) error
|
||||
Update(ctx context.Context, rule *LLMPricingRule) error
|
||||
List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*StorableLLMPricingRule, int, error)
|
||||
Get(ctx context.Context, orgID, id valuer.UUID) (*StorableLLMPricingRule, error)
|
||||
GetBySourceID(ctx context.Context, orgID, sourceID valuer.UUID) (*StorableLLMPricingRule, error)
|
||||
Create(ctx context.Context, rule *StorableLLMPricingRule) error
|
||||
Update(ctx context.Context, rule *StorableLLMPricingRule) error
|
||||
Delete(ctx context.Context, orgID, id valuer.UUID) error
|
||||
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user