Compare commits

..

1 Commits

Author SHA1 Message Date
Pradeep Kumar
74811b36c8 feat: adds overview page.
Added system dashboard API endpoints under /api/v1/system/{source}/dashboard
GET /api/v1/system/ai-o11y-overview/dashboard
PUT /api/v1/system/ai-o11y-overview/dashboard

reset endpoint to removed any edited dashboard and reset the default values.
POST /api/v1/system/ai-o11y-overview/dashboard/reset

seeding at two points,
- at org creation,
- existing org migration.

change delete to reset
2026-04-29 18:50:02 +05:30
108 changed files with 2269 additions and 6215 deletions

View File

@@ -27,7 +27,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
@@ -101,8 +100,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), store, settings, analytics, orgGetter, queryParser, tagModule)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser)
},
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()

View File

@@ -42,7 +42,6 @@ import (
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
@@ -145,8 +144,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, dashboardModule), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), store, settings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)
},
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)

File diff suppressed because it is too large Load Diff

View File

@@ -11,15 +11,12 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes/dashboardtypesv2"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -33,9 +30,9 @@ type module struct {
licensing licensing.Licensing
}
func NewModule(store dashboardtypes.Store, sqlstore sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
pkgDashboardModule := pkgimpldashboard.NewModule(store, sqlstore, settings, analytics, orgGetter, queryParser, tagModule)
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser)
return &module{
pkgDashboardModule: pkgDashboardModule,
@@ -200,10 +197,6 @@ func (module *module) Create(ctx context.Context, orgID valuer.UUID, createdBy s
return module.pkgDashboardModule.Create(ctx, orgID, createdBy, creator, data)
}
func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypesv2.PostableDashboard) (*dashboardtypesv2.Dashboard, error) {
return module.pkgDashboardModule.CreateV2(ctx, orgID, createdBy, creator, postable)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

View File

@@ -18,12 +18,10 @@ import type {
} from 'react-query';
import type {
CreateDashboardV2201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatablePublicDashboardDTO,
Dashboardtypesv2PostableDashboardDTO,
DeletePublicDashboardPathParameters,
GetPublicDashboard200,
GetPublicDashboardData200,
@@ -636,88 +634,3 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* This endpoint creates a v2-shape dashboard with structured metadata, a typed data tree, and resolved tags.
* @summary Create dashboard (v2)
*/
export const createDashboardV2 = (
dashboardtypesv2PostableDashboardDTO: BodyType<Dashboardtypesv2PostableDashboardDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateDashboardV2201>({
url: `/api/v2/dashboards`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesv2PostableDashboardDTO,
signal,
});
};
export const getCreateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data: BodyType<Dashboardtypesv2PostableDashboardDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data: BodyType<Dashboardtypesv2PostableDashboardDTO> },
TContext
> => {
const mutationKey = ['createDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createDashboardV2>>,
{ data: BodyType<Dashboardtypesv2PostableDashboardDTO> }
> = (props) => {
const { data } = props ?? {};
return createDashboardV2(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof createDashboardV2>>
>;
export type CreateDashboardV2MutationBody =
BodyType<Dashboardtypesv2PostableDashboardDTO>;
export type CreateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create dashboard (v2)
*/
export const useCreateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data: BodyType<Dashboardtypesv2PostableDashboardDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data: BodyType<Dashboardtypesv2PostableDashboardDTO> },
TContext
> => {
const mutationOptions = getCreateDashboardV2MutationOptions(options);
return useMutation(mutationOptions);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,318 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
DashboardtypesUpdatableSystemDashboardDTO,
GetSystemDashboard200,
GetSystemDashboardPathParameters,
RenderErrorResponseDTO,
ResetSystemDashboard200,
ResetSystemDashboardPathParameters,
UpdateSystemDashboard200,
UpdateSystemDashboardPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* This endpoint returns the system dashboard for the callers org keyed by source (e.g. ai-o11y-overview).
* @summary Get system dashboard
*/
export const getSystemDashboard = (
{ source }: GetSystemDashboardPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetSystemDashboard200>({
url: `/api/v1/system/${source}/dashboard`,
method: 'GET',
signal,
});
};
export const getGetSystemDashboardQueryKey = ({
source,
}: GetSystemDashboardPathParameters) => {
return [`/api/v1/system/${source}/dashboard`] as const;
};
export const getGetSystemDashboardQueryOptions = <
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ source }: GetSystemDashboardPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetSystemDashboardQueryKey({ source });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getSystemDashboard>>
> = ({ signal }) => getSystemDashboard({ source }, signal);
return {
queryKey,
queryFn,
enabled: !!source,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetSystemDashboardQueryResult = NonNullable<
Awaited<ReturnType<typeof getSystemDashboard>>
>;
export type GetSystemDashboardQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get system dashboard
*/
export function useGetSystemDashboard<
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ source }: GetSystemDashboardPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetSystemDashboardQueryOptions({ source }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get system dashboard
*/
export const invalidateGetSystemDashboard = async (
queryClient: QueryClient,
{ source }: GetSystemDashboardPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetSystemDashboardQueryKey({ source }) },
options,
);
return queryClient;
};
/**
* This endpoint replaces the system dashboard for the callers org with the provided payload.
* @summary Update system dashboard
*/
export const updateSystemDashboard = (
{ source }: UpdateSystemDashboardPathParameters,
dashboardtypesUpdatableSystemDashboardDTO: BodyType<DashboardtypesUpdatableSystemDashboardDTO>,
) => {
return GeneratedAPIInstance<UpdateSystemDashboard200>({
url: `/api/v1/system/${source}/dashboard`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesUpdatableSystemDashboardDTO,
});
};
export const getUpdateSystemDashboardMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateSystemDashboard>>,
TError,
{
pathParams: UpdateSystemDashboardPathParameters;
data: BodyType<DashboardtypesUpdatableSystemDashboardDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateSystemDashboard>>,
TError,
{
pathParams: UpdateSystemDashboardPathParameters;
data: BodyType<DashboardtypesUpdatableSystemDashboardDTO>;
},
TContext
> => {
const mutationKey = ['updateSystemDashboard'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateSystemDashboard>>,
{
pathParams: UpdateSystemDashboardPathParameters;
data: BodyType<DashboardtypesUpdatableSystemDashboardDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateSystemDashboard(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateSystemDashboardMutationResult = NonNullable<
Awaited<ReturnType<typeof updateSystemDashboard>>
>;
export type UpdateSystemDashboardMutationBody =
BodyType<DashboardtypesUpdatableSystemDashboardDTO>;
export type UpdateSystemDashboardMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update system dashboard
*/
export const useUpdateSystemDashboard = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateSystemDashboard>>,
TError,
{
pathParams: UpdateSystemDashboardPathParameters;
data: BodyType<DashboardtypesUpdatableSystemDashboardDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateSystemDashboard>>,
TError,
{
pathParams: UpdateSystemDashboardPathParameters;
data: BodyType<DashboardtypesUpdatableSystemDashboardDTO>;
},
TContext
> => {
const mutationOptions = getUpdateSystemDashboardMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint drops any customisation to the system dashboard, writes the defaults back, and returns the freshly seeded dashboard.
* @summary Reset system dashboard to defaults
*/
export const resetSystemDashboard = (
{ source }: ResetSystemDashboardPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ResetSystemDashboard200>({
url: `/api/v1/system/${source}/dashboard/reset`,
method: 'POST',
signal,
});
};
export const getResetSystemDashboardMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof resetSystemDashboard>>,
TError,
{ pathParams: ResetSystemDashboardPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof resetSystemDashboard>>,
TError,
{ pathParams: ResetSystemDashboardPathParameters },
TContext
> => {
const mutationKey = ['resetSystemDashboard'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof resetSystemDashboard>>,
{ pathParams: ResetSystemDashboardPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return resetSystemDashboard(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type ResetSystemDashboardMutationResult = NonNullable<
Awaited<ReturnType<typeof resetSystemDashboard>>
>;
export type ResetSystemDashboardMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Reset system dashboard to defaults
*/
export const useResetSystemDashboard = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof resetSystemDashboard>>,
TError,
{ pathParams: ResetSystemDashboardPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof resetSystemDashboard>>,
TError,
{ pathParams: ResetSystemDashboardPathParameters },
TContext
> => {
const mutationOptions = getResetSystemDashboardMutationOptions(options);
return useMutation(mutationOptions);
};

View File

@@ -1,22 +0,0 @@
.codeBlock {
position: relative;
}
.codeBlockSyntaxHighlighter {
background-color: var(--l2-background) !important;
border-radius: 4px !important;
border: 1px solid var(--l2-border) !important;
color: var(--l2-foreground) !important;
pre {
color: var(--l2-foreground) !important;
font-family: 'Geist Mono' !important;
font-size: 12px !important;
}
code {
color: var(--l1-foreground) !important;
font-family: 'Geist Mono' !important;
font-size: 12px !important;
}
}

View File

@@ -1,46 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import CodeBlock from './CodeBlock';
const mockCopyToClipboard = jest.fn();
jest.mock('react-use', () => ({
useCopyToClipboard: (): [unknown, (text: string) => void] => [
undefined,
mockCopyToClipboard,
],
}));
describe('CodeBlock', () => {
beforeEach(() => {
mockCopyToClipboard.mockReset();
});
it('renders code block mode by default', () => {
render(<CodeBlock code={'const x = 1;\n'} language="javascript" />);
const container = screen.getByTestId('code-block-container');
expect(container).toBeInTheDocument();
expect(container).toHaveTextContent('const x = 1;');
});
it('renders inline code when inline is true', () => {
render(<CodeBlock code="inline value" inline />);
const inlineCode = screen.getByText('inline value');
expect(inlineCode.tagName.toLowerCase()).toBe('code');
expect(screen.queryByTestId('code-block-container')).not.toBeInTheDocument();
});
it('copies code and triggers callback', async () => {
const onCopy = jest.fn();
render(<CodeBlock code="SELECT * FROM logs;" onCopy={onCopy} />);
fireEvent.click(screen.getByRole('button', { name: /copy code/i }));
await waitFor(() => {
expect(mockCopyToClipboard).toHaveBeenCalledWith('SELECT * FROM logs;');
});
expect(onCopy).toHaveBeenCalledWith('SELECT * FROM logs;');
});
});

View File

@@ -1,89 +0,0 @@
import { useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Check, Copy } from '@signozhq/icons';
import { Button } from '@signozhq/ui';
import SyntaxHighlighter, {
a11yDark,
} from 'components/MarkdownRenderer/syntaxHighlighter';
import styles from './CodeBlock.module.scss';
export interface CodeBlockProps {
code: string;
language?: string;
className?: string;
inline?: boolean;
showLineNumbers?: boolean;
showCopyButton?: boolean;
onCopy?: (copiedCode: string) => void;
}
function CodeBlock({
code,
language = 'text',
className,
inline = false,
showLineNumbers = false,
showCopyButton = true,
onCopy,
}: CodeBlockProps): JSX.Element {
const [isCopied, setIsCopied] = useState(false);
const [, copyToClipboard] = useCopyToClipboard();
const normalizedCode = useMemo(() => code?.replace(/\n$/, '') ?? '', [code]);
const handleCopy = (): void => {
copyToClipboard(normalizedCode);
setIsCopied(true);
onCopy?.(normalizedCode);
setTimeout(() => {
setIsCopied(false);
}, 1000);
};
if (inline) {
return <code className={className}>{normalizedCode}</code>;
}
return (
<div
className={`${styles.codeBlock} ${className}`}
style={{ position: 'relative' }}
data-testid="code-block-container"
>
{showCopyButton ? (
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={handleCopy}
prefix={isCopied ? <Check size={14} /> : <Copy size={14} />}
aria-label="Copy code"
title={isCopied ? 'Copied' : 'Copy'}
style={{ position: 'absolute', right: 8, top: 8, zIndex: 1 }}
/>
) : null}
<SyntaxHighlighter
style={a11yDark}
language={language}
PreTag="div"
showLineNumbers={showLineNumbers}
wrapLongLines
className={styles.codeBlockSyntaxHighlighter}
>
{normalizedCode}
</SyntaxHighlighter>
</div>
);
}
CodeBlock.defaultProps = {
language: 'text',
className: undefined,
inline: false,
showLineNumbers: false,
showCopyButton: true,
onCopy: undefined,
};
export default CodeBlock;

View File

@@ -104,12 +104,7 @@ export const usePanelContextMenu = ({
}
if (data && data?.record?.queryName) {
onClick(data.coord, {
...data.record,
label: data.label,
seriesColor: data.seriesColor,
timeRange,
});
onClick(data.coord, { ...data.record, label: data.label, timeRange });
}
},
[onClick, queryResponse],

View File

@@ -1,346 +0,0 @@
.cloud-account-setup-modal {
background: var(--l1-background);
color: var(--l1-foreground);
[data-slot='drawer-title'] {
color: var(--l1-foreground);
}
> div {
display: flex;
flex-direction: column;
overflow: hidden;
}
&__content {
flex: 1;
overflow-y: auto;
min-height: 0;
scrollbar-width: thin;
padding-right: 16px;
&::-webkit-scrollbar-thumb {
background: var(--l1-border);
}
&::-webkit-scrollbar-track {
background: var(--l1-background);
}
scrollbar-width: thin;
scrollbar-color: var(--l3-background) var(--l1-background);
}
.cloud-account-setup-prerequisites {
display: flex;
flex-direction: column;
gap: 12px;
&__title {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
&__list {
display: flex;
flex-direction: column;
gap: 8px;
list-style: none;
padding: 0;
margin: 0;
}
&__list-item {
color: var(--l2-foreground);
font-size: 13px;
line-height: 18px;
letter-spacing: -0.06px;
display: flex;
align-items: center;
gap: 16px;
&-bullet {
color: var(--primary);
font-weight: 500;
}
&-text {
display: flex;
align-items: center;
gap: 4px;
}
}
&__list-item-highlight {
color: var(--l1-foreground);
font-weight: 500;
}
}
.cloud-account-setup-how-it-works-accordion {
display: flex;
flex-direction: column;
margin: 24px 0;
&__title {
display: flex;
align-items: center;
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
border-radius: 4px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
padding: 4px 16px 4px 0px;
&.open {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
&__description {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
opacity: 0;
transform: translateY(-8px);
animation: cloud-account-setup-accordion-reveal 220ms ease-out forwards;
border-radius: 4px;
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
border: 1px solid var(--l2-border);
background: var(--l2-background);
&-item {
display: flex;
flex-direction: column;
gap: 8px;
color: var(--l1-foreground);
font-size: 13px;
line-height: 18px;
letter-spacing: -0.06px;
}
@media (prefers-reduced-motion: reduce) {
animation: none;
opacity: 1;
transform: none;
}
}
}
.cloud-account-setup-form__code-block-tabs {
padding: 8px;
border-radius: 4px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
&-header {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 12px;
&-title {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
&-description {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
}
[role='tablist'] {
gap: 8px !important;
}
[role='tabpanel'] {
padding: 0 !important;
}
[data-slot='tabs-trigger'] {
padding: 4px 24px !important;
border: none !important;
background-color: transparent !important;
font-size: 12px !important;
}
}
@keyframes cloud-account-setup-accordion-reveal {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.account-setup-modal-footer {
&__confirm-button {
background: var(--primary-background);
color: var(--primary-foreground);
font-size: 12px;
font-weight: 500;
}
&__confirm-selection-count {
font-family: 'Geist Mono';
}
&__close-button {
background: var(--l1-background);
border: 1px solid var(--l1-border);
border-radius: 2px;
color: var(--l1-foreground);
font-family: 'Inter';
font-size: 12px;
font-weight: 500;
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
}
}
.cloud-account-setup-form {
.disabled {
opacity: 0.4;
}
&,
&__content {
display: flex;
flex-direction: column;
gap: 24px;
}
&__alert {
width: 100%;
[data-slot='callout'] {
width: 100%;
box-sizing: border-box;
}
&-message {
display: flex;
align-items: center;
gap: 8px;
color: var(--l1-foreground);
.retry-time {
font-family: 'Geist Mono';
font-size: 14px;
font-weight: 600;
line-height: 22px;
letter-spacing: -0.07px;
}
}
}
&__form-group {
display: flex;
flex-direction: column;
gap: 12px;
}
&__title {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
&__description {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
&__select {
.ant-select-selection-item {
color: var(--l1-foreground);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
}
&__form-item {
margin: 0;
}
&__include-all-regions-switch {
display: flex;
align-items: center;
gap: 10px;
color: var(--l2-foreground);
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
margin-bottom: 12px;
&-label {
background-color: transparent;
border: none;
color: var(--l2-foreground);
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
cursor: pointer;
}
}
&__note {
padding: 12px;
color: var(--callout-primary-description);
font-size: 12px;
line-height: 22px;
letter-spacing: -0.06px;
border-radius: 4px;
border: 1px solid
color-mix(in srgb, var(--primary-background) 10%, transparent);
background: color-mix(in srgb, var(--primary-background) 10%, transparent);
}
&__submit-button {
border-radius: 2px;
background: var(--primary-background);
color: var(--primary-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
&-content {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
&:disabled {
opacity: 0.4;
}
}
}
}

View File

@@ -0,0 +1,28 @@
import awsDarkLogoUrl from '@/assets/Logos/aws-dark.svg';
import AccountActions from './components/AccountActions';
import './HeroSection.style.scss';
function HeroSection(): JSX.Element {
return (
<div className="hero-section">
<div className="hero-section__details">
<div className="hero-section__details-header">
<div className="hero-section__icon">
<img src={awsDarkLogoUrl} alt="AWS" />
</div>
<div className="hero-section__details-title">AWS</div>
</div>
<div className="hero-section__details-description">
AWS is a cloud computing platform that provides a range of services for
building and running applications.
</div>
</div>
<AccountActions />
</div>
);
}
export default HeroSection;

View File

@@ -117,12 +117,6 @@
min-width: 140px !important;
}
&.azure {
.ant-select-selector {
min-width: 282px !important;
}
}
.ant-select-item-option-active {
background: var(--l3-background) !important;
}

View File

@@ -1,4 +1,4 @@
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import { Color } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui';
@@ -6,29 +6,19 @@ import { Select, Skeleton } from 'antd';
import { SelectProps } from 'antd/lib';
import logEvent from 'api/common/logEvent';
import { useListAccounts } from 'api/generated/services/cloudintegration';
import cx from 'classnames';
import { getAccountById } from 'container/Integrations/CloudIntegration/utils';
import {
CloudAccount as IntegrationCloudAccount,
IntegrationType,
} from 'container/Integrations/types';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import useUrlQuery from 'hooks/useUrlQuery';
import { ChevronDown, Dot, PencilLine, Plug, Plus } from 'lucide-react';
import AzureCloudAccountSetupModal from '../../AzureCloudServices/AddNewAccount/CloudAccountSetupModal';
import AzureAccountSettingsModal from '../../AzureCloudServices/EditAccount/AccountSettingsModal';
import {
mapAccountDtoToAwsCloudAccount,
mapAccountDtoToAzureCloudAccount,
} from '../../mapCloudAccountFromDto';
import AwsCloudAccountSetupModal from '../AddNewAccount/CloudAccountSetupModal';
import AwsAccountSettingsModal from '../EditAccount/AccountSettingsModal';
import { CloudAccount as AwsCloudAccount } from '../types';
import { mapAccountDtoToAwsCloudAccount } from '../../mapAwsCloudAccountFromDto';
import { CloudAccount } from '../../types';
import AccountSettingsModal from './AccountSettingsModal';
import CloudAccountSetupModal from './CloudAccountSetupModal';
import './AccountActions.style.scss';
function AccountActionsRenderer({
type,
accounts,
isLoading,
activeAccount,
@@ -37,10 +27,9 @@ function AccountActionsRenderer({
onIntegrationModalOpen,
onAccountSettingsModalOpen,
}: {
type: IntegrationType;
accounts: IntegrationCloudAccount[] | undefined;
accounts: CloudAccount[] | undefined;
isLoading: boolean;
activeAccount: IntegrationCloudAccount | null;
activeAccount: CloudAccount | null;
selectOptions: SelectProps['options'];
onAccountChange: (value: string) => void;
onIntegrationModalOpen: () => void;
@@ -68,11 +57,9 @@ function AccountActionsRenderer({
<Select
value={activeAccount?.providerAccountId}
options={selectOptions}
rootClassName={cx('cloud-account-selector', {
[type.toLowerCase()]: type,
})}
rootClassName="cloud-account-selector"
popupMatchSelectWidth={false}
placeholder={`Select ${type} Account`}
placeholder="Select AWS Account"
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
onChange={onAccountChange}
/>
@@ -115,49 +102,21 @@ function AccountActionsRenderer({
);
}
function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
function AccountActions(): JSX.Element {
const urlQuery = useUrlQuery();
const navigate = useNavigate();
const { data: listAccountsResponse, isLoading } = useListAccounts({
cloudProvider: type,
cloudProvider: INTEGRATION_TYPES.AWS,
});
const accounts = useMemo((): IntegrationCloudAccount[] | undefined => {
const accounts = useMemo((): CloudAccount[] | undefined => {
const raw = listAccountsResponse?.data?.accounts;
if (!raw) {
return undefined;
}
const mappedAccounts: IntegrationCloudAccount[] = [];
if (type === IntegrationType.AWS_SERVICES) {
raw.forEach((account) => {
if (!account) {
return;
}
const mapped = mapAccountDtoToAwsCloudAccount(account);
if (mapped) {
mappedAccounts.push(mapped);
}
});
}
if (type === IntegrationType.AZURE_SERVICES) {
raw.forEach((account) => {
if (!account) {
return;
}
const mapped = mapAccountDtoToAzureCloudAccount(account);
if (mapped) {
mappedAccounts.push(mapped);
}
});
}
return mappedAccounts;
}, [listAccountsResponse, type]);
return raw
.map(mapAccountDtoToAwsCloudAccount)
.filter((account): account is CloudAccount => account !== null);
}, [listAccountsResponse]);
const initialAccount = useMemo(
() =>
@@ -168,8 +127,9 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
[accounts, urlQuery],
);
const [activeAccount, setActiveAccount] =
useState<IntegrationCloudAccount | null>(initialAccount);
const [activeAccount, setActiveAccount] = useState<CloudAccount | null>(
initialAccount,
);
// Update state when initial value changes
useEffect(() => {
@@ -189,17 +149,16 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
}, [initialAccount]);
const [isIntegrationModalOpen, setIsIntegrationModalOpen] = useState(false);
const startAccountConnectionAttempt = (): void => {
setIsIntegrationModalOpen(true);
logEvent(`${type} Integration: Account connection attempt started`, {});
logEvent('AWS Integration: Account connection attempt started', {});
};
const [isAccountSettingsModalOpen, setIsAccountSettingsModalOpen] =
useState(false);
const openAccountSettings = (): void => {
setIsAccountSettingsModalOpen(true);
logEvent(`${type} Integration: Account settings viewed`, {
logEvent('AWS Integration: Account settings viewed', {
cloudAccountId: activeAccount?.cloud_account_id,
});
};
@@ -207,16 +166,13 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
// log telemetry event when an account is viewed.
useEffect(() => {
if (activeAccount) {
logEvent(`${type} Integration: Account viewed`, {
logEvent('AWS Integration: Account viewed', {
cloudAccountId: activeAccount?.cloud_account_id,
status: activeAccount?.status,
enabledRegions:
'regions' in activeAccount.config
? activeAccount.config.regions
: activeAccount.config.resource_groups,
enabledRegions: activeAccount?.config?.regions,
});
}
}, [activeAccount, type]);
}, [activeAccount]);
const selectOptions: SelectProps['options'] = useMemo(
() =>
@@ -232,7 +188,6 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
return (
<div className="hero-section__actions">
<AccountActionsRenderer
type={type}
accounts={accounts}
isLoading={isLoading}
activeAccount={activeAccount}
@@ -249,39 +204,17 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
/>
{isIntegrationModalOpen && (
<>
{type === IntegrationType.AWS_SERVICES && (
<AwsCloudAccountSetupModal
onClose={(): void => setIsIntegrationModalOpen(false)}
/>
)}
{type === IntegrationType.AZURE_SERVICES && (
<AzureCloudAccountSetupModal
onClose={(): void => setIsIntegrationModalOpen(false)}
/>
)}
</>
<CloudAccountSetupModal
onClose={(): void => setIsIntegrationModalOpen(false)}
/>
)}
{isAccountSettingsModalOpen && activeAccount && (
<>
{type === IntegrationType.AWS_SERVICES && (
<AwsAccountSettingsModal
onClose={(): void => setIsAccountSettingsModalOpen(false)}
account={activeAccount as AwsCloudAccount}
setActiveAccount={
setActiveAccount as Dispatch<SetStateAction<AwsCloudAccount | null>>
}
/>
)}
{type === IntegrationType.AZURE_SERVICES && (
<AzureAccountSettingsModal
onClose={(): void => setIsAccountSettingsModalOpen(false)}
account={activeAccount}
setActiveAccount={setActiveAccount}
/>
)}
</>
<AccountSettingsModal
onClose={(): void => setIsAccountSettingsModalOpen(false)}
account={activeAccount}
setActiveAccount={setActiveAccount}
/>
)}
</div>
);

View File

@@ -9,10 +9,10 @@ import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { Save } from 'lucide-react';
import logEvent from '../../../../../api/common/logEvent';
import RemoveIntegrationAccount from '../../RemoveAccount/RemoveIntegrationAccount';
import { RegionSelector } from '../RegionForm/RegionSelector';
import { CloudAccount } from '../types';
import logEvent from '../../../../../../api/common/logEvent';
import { CloudAccount } from '../../types';
import { RegionSelector } from './RegionSelector';
import RemoveIntegrationAccount from './RemoveIntegrationAccount';
import './AccountSettingsModal.style.scss';
@@ -110,7 +110,11 @@ function AccountSettingsModal({
form,
selectedRegions,
includeAllRegions,
account?.providerAccountId,
account?.id,
handleRemoveIntegrationAccountSuccess,
isSaveDisabled,
handleSubmit,
isLoading,
setSelectedRegions,
setIncludeAllRegions,
]);
@@ -129,7 +133,6 @@ function AccountSettingsModal({
<RemoveIntegrationAccount
accountId={account?.id}
onRemoveIntegrationAccountSuccess={handleRemoveIntegrationAccountSuccess}
cloudProvider={INTEGRATION_TYPES.AWS}
/>
<Button

View File

@@ -1,8 +1,8 @@
import { Callout } from '@signozhq/ui';
import { Spin } from 'antd';
import { LoaderCircle } from 'lucide-react';
import { Color } from '@signozhq/design-tokens';
import { Alert, Spin } from 'antd';
import { LoaderCircle, TriangleAlert } from 'lucide-react';
import { ModalStateEnum } from '../HeroSection/types';
import { ModalStateEnum } from '../types';
function AlertMessage({
modalState,
@@ -12,13 +12,14 @@ function AlertMessage({
switch (modalState) {
case ModalStateEnum.WAITING:
return (
<Callout
title={
<Alert
message={
<div className="cloud-account-setup-form__alert-message">
<Spin
indicator={
<LoaderCircle
size={14}
color={Color.BG_AMBER_400}
className="anticon anticon-loading anticon-spin ant-spin-dot"
/>
}
@@ -27,19 +28,21 @@ function AlertMessage({
<span className="retry-time">10</span> secs...
</div>
}
type="info"
showIcon={false}
className="cloud-account-setup-form__alert"
type="warning"
/>
);
case ModalStateEnum.ERROR:
return (
<Callout
title={
<Alert
message={
<div className="cloud-account-setup-form__alert-message">
<TriangleAlert type="solid" size={15} color={Color.BG_SAKURA_400} />
{`We couldn't establish a connection to your AWS account. Please try again`}
</div>
}
type="error"
className="cloud-account-setup-form__alert"
/>
);
default:

View File

@@ -0,0 +1,180 @@
.cloud-account-setup-modal {
background: var(--l1-background);
color: var(--l1-foreground);
[data-slot='drawer-title'] {
color: var(--l1-foreground);
}
> div {
display: flex;
flex-direction: column;
overflow: hidden;
}
&__content {
flex: 1;
overflow-y: auto;
min-height: 0;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 2px;
}
}
.account-setup-modal-footer {
&__confirm-button {
background: var(--primary-background);
color: var(--primary-foreground);
font-size: 12px;
font-weight: 500;
}
&__confirm-selection-count {
font-family: 'Geist Mono';
}
&__close-button {
background: var(--l1-background);
border: 1px solid var(--l1-border);
border-radius: 2px;
color: var(--l1-foreground);
font-family: 'Inter';
font-size: 12px;
font-weight: 500;
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
}
}
.cloud-account-setup-form {
.disabled {
opacity: 0.4;
}
&,
&__content {
display: flex;
flex-direction: column;
gap: 24px;
}
&__alert {
&.ant-alert {
padding: 12px;
border-radius: 6px;
font-size: 14px;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
}
&.ant-alert-error {
color: var(--danger-foreground);
border: 1px solid
color-mix(in srgb, var(--danger-background) 10%, transparent);
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
}
&.ant-alert-warning {
color: var(--warning-foreground);
border: 1px solid
color-mix(in srgb, var(--warning-background) 10%, transparent);
background: color-mix(in srgb, var(--warning-background) 10%, transparent);
}
&-message {
display: flex;
align-items: center;
gap: 8px;
color: var(--l1-foreground);
.retry-time {
font-family: 'Geist Mono';
font-size: 14px;
font-weight: 600;
line-height: 22px;
letter-spacing: -0.07px;
}
}
}
&__form-group {
display: flex;
flex-direction: column;
gap: 12px;
}
&__title {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
&__description {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
&__select {
.ant-select-selection-item {
color: var(--l1-foreground);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
}
&__form-item {
margin: 0;
}
&__include-all-regions-switch {
display: flex;
align-items: center;
gap: 10px;
color: var(--l2-foreground);
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
margin-bottom: 12px;
&-label {
background-color: transparent;
border: none;
color: var(--l2-foreground);
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
cursor: pointer;
}
}
&__note {
padding: 12px;
color: var(--callout-primary-description);
font-size: 12px;
line-height: 22px;
letter-spacing: -0.06px;
border-radius: 4px;
border: 1px solid
color-mix(in srgb, var(--primary-background) 10%, transparent);
background: color-mix(in srgb, var(--primary-background) 10%, transparent);
}
&__submit-button {
border-radius: 2px;
background: var(--primary-background);
color: var(--primary-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
&-content {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
&:disabled {
opacity: 0.4;
}
}
}
}

View File

@@ -8,8 +8,8 @@ import {
ActiveViewEnum,
IntegrationModalProps,
ModalStateEnum,
} from '../../../HeroSection/types';
import { RegionForm } from '../RegionForm/RegionForm';
} from '../types';
import { RegionForm } from './RegionForm';
import './CloudAccountSetupModal.style.scss';
@@ -74,6 +74,8 @@ function CloudAccountSetupModal({
isConnectionParamsLoading,
setSelectedRegions,
setIncludeAllRegions,
isLoading,
isGeneratingUrl,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,

View File

@@ -5,7 +5,7 @@ import { ChevronDown } from 'lucide-react';
import { Region } from 'utils/regions';
import { popupContainer } from 'utils/selectPopupContainer';
import { RegionSelector } from './RegionForm/RegionSelector';
import { RegionSelector } from './RegionSelector';
// Form section components
function RegionDeploymentSection({

View File

@@ -3,18 +3,15 @@ import { Form } from 'antd';
import { useGetAccount } from 'api/generated/services/cloudintegration';
import cx from 'classnames';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import {
ModalStateEnum,
RegionFormProps,
} from 'container/Integrations/HeroSection/types';
import { regions } from 'utils/regions';
import AlertMessage from '../../AlertMessage';
import { ModalStateEnum, RegionFormProps } from '../types';
import AlertMessage from './AlertMessage';
import {
ComplianceNote,
MonitoringRegionsSection,
RegionDeploymentSection,
} from '../IntegrateNowFormSections';
} from './IntegrateNowFormSections';
import RenderConnectionFields from './RenderConnectionParams';
export function RegionForm({
@@ -79,6 +76,8 @@ export function RegionForm({
layout="vertical"
onFinish={onSubmit}
>
<AlertMessage modalState={modalState} />
<div
className={cx(`cloud-account-setup-form__content`, {
disabled: isFormDisabled,
@@ -101,10 +100,6 @@ export function RegionForm({
isFormDisabled={isFormDisabled}
/>
</div>
<div className="cloud-account-setup-form__alert">
<AlertMessage modalState={modalState} />
</div>
</Form>
);
}

View File

@@ -1,16 +1,9 @@
.remove-integration-account-modal {
&__cloud-provider {
color: var(--l1-foreground);
font-weight: 500;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
.ant-modal-content {
background-color: var(--l1-background);
border: 1px solid var(--l3-background);
border-radius: 4px;
padding: 12px;
}
.ant-modal-close {

View File

@@ -4,21 +4,16 @@ import { Modal } from 'antd/lib';
import logEvent from 'api/common/logEvent';
import { useDisconnectAccount } from 'api/generated/services/cloudintegration';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import {
INTEGRATION_TELEMETRY_EVENTS,
INTEGRATION_TYPES,
} from 'container/Integrations/constants';
import { INTEGRATION_TELEMETRY_EVENTS } from 'container/Integrations/constants';
import { useNotifications } from 'hooks/useNotifications';
import { Unlink } from 'lucide-react';
import './RemoveIntegrationAccount.scss';
function RemoveIntegrationAccount({
cloudProvider,
accountId,
onRemoveIntegrationAccountSuccess,
}: {
cloudProvider: string;
accountId: string;
onRemoveIntegrationAccountSuccess: () => void;
}): JSX.Element {
@@ -44,13 +39,12 @@ function RemoveIntegrationAccount({
},
});
const handleOk = (): void => {
logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATION_ACCOUNT_REMOVED, {
logEvent(INTEGRATION_TELEMETRY_EVENTS.AWS_INTEGRATION_ACCOUNT_REMOVED, {
accountId,
integration: cloudProvider,
});
disconnectAccount({
pathParams: {
cloudProvider,
cloudProvider: 'aws',
id: accountId,
},
});
@@ -84,28 +78,13 @@ function RemoveIntegrationAccount({
loading: isRemoveIntegrationLoading,
}}
>
{cloudProvider === INTEGRATION_TYPES.AWS ? (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your AWS account within the next ~15 minutes
(cloudformation stacks named signoz-integration-telemetry-collection in
enabled regions). <br />
<br />
After that, you can delete the cloudformation stack that was created
manually when connecting this account.
</>
) : (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your Azure subscription within the next ~15 minutes
(deployment stack named signoz-integration-telemetry will be deleted
automatically). <br />
<br />
After that, you have to manually delete &apos;signoz-integration&apos;
deployment stack that was created while connecting this account (Takes ~20
minutes to delete).
</>
)}
Removing this account will remove all components created for sending
telemetry to SigNoz in your AWS account within the next ~15 minutes
(cloudformation stacks named signoz-integration-telemetry-collection in
enabled regions). <br />
<br />
After that, you can delete the cloudformation stack that was created
manually when connecting this account.
</Modal>
</div>
);

View File

@@ -4,7 +4,7 @@ import { useListAccounts } from 'api/generated/services/cloudintegration';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import useUrlQuery from 'hooks/useUrlQuery';
import { mapAccountDtoToAwsCloudAccount } from '../../mapCloudAccountFromDto';
import { mapAccountDtoToAwsCloudAccount } from '../mapAwsCloudAccountFromDto';
import { CloudAccount } from '../types';
import './S3BucketsSelector.styles.scss';

View File

@@ -12,14 +12,14 @@ import {
useUpdateService,
} from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesServiceConfigDTO,
CloudintegrationtypesServiceDTO,
ListServicesMetadata200,
} from 'api/generated/services/sigNoz.schemas';
import CloudServiceDataCollected from 'components/CloudIntegrations/CloudServiceDataCollected/CloudServiceDataCollected';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import ServiceDashboards from 'container/Integrations/CloudIntegration/ServiceDashboards/ServiceDashboards';
import { IntegrationType, IServiceStatus } from 'container/Integrations/types';
import ServiceDashboards from 'container/Integrations/CloudIntegration/AmazonWebServices/ServiceDashboards/ServiceDashboards';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { IServiceStatus } from 'container/Integrations/types';
import useUrlQuery from 'hooks/useUrlQuery';
import { Save, X } from 'lucide-react';
@@ -36,81 +36,7 @@ type ServiceDetailsData = CloudintegrationtypesServiceDTO & {
status?: IServiceStatus;
};
const EMPTY_FORM_VALUES: ServiceConfigFormValues = {
logsEnabled: false,
metricsEnabled: false,
s3BucketsByRegion: {},
};
function getInitialFormValues(
type: IntegrationType,
serviceDetailsData?: ServiceDetailsData,
): ServiceConfigFormValues {
const integrationConfig =
type === IntegrationType.AWS_SERVICES
? serviceDetailsData?.cloudIntegrationService?.config?.aws
: serviceDetailsData?.cloudIntegrationService?.config?.azure;
return {
logsEnabled: integrationConfig?.logs?.enabled || false,
metricsEnabled: integrationConfig?.metrics?.enabled || false,
s3BucketsByRegion:
type === IntegrationType.AWS_SERVICES
? serviceDetailsData?.cloudIntegrationService?.config?.aws?.logs
?.s3Buckets || {}
: {},
};
}
function getServiceConfigPayload({
type,
serviceId,
logsEnabled,
metricsEnabled,
isLogsSupported,
isMetricsSupported,
s3BucketsByRegion,
}: {
type: IntegrationType;
serviceId: string;
logsEnabled: boolean;
metricsEnabled: boolean;
isLogsSupported: boolean;
isMetricsSupported: boolean;
s3BucketsByRegion: Record<string, string[]>;
}): CloudintegrationtypesServiceConfigDTO {
if (type === IntegrationType.AWS_SERVICES) {
return {
aws: {
logs: {
enabled: isLogsSupported ? logsEnabled : false,
s3Buckets:
serviceId === 's3sync' && isLogsSupported ? s3BucketsByRegion : {},
},
metrics: {
enabled: isMetricsSupported ? metricsEnabled : false,
},
},
};
}
return {
azure: {
logs: {
enabled: isLogsSupported ? logsEnabled : false,
},
metrics: {
enabled: isMetricsSupported ? metricsEnabled : false,
},
},
};
}
function ServiceDetails({
type,
}: {
type: IntegrationType;
}): JSX.Element | null {
function ServiceDetails(): JSX.Element | null {
const urlQuery = useUrlQuery();
const cloudAccountId = urlQuery.get('cloudAccountId');
const serviceId = urlQuery.get('service');
@@ -125,7 +51,7 @@ function ServiceDetails({
isLoading: isServiceDetailsLoading,
} = useGetService(
{
cloudProvider: type,
cloudProvider: INTEGRATION_TYPES.AWS,
serviceId: serviceId || '',
},
{
@@ -139,17 +65,10 @@ function ServiceDetails({
},
);
const integrationConfig =
type === IntegrationType.AWS_SERVICES
? serviceDetailsData?.cloudIntegrationService?.config?.aws
: serviceDetailsData?.cloudIntegrationService?.config?.azure;
const awsConfig = serviceDetailsData?.cloudIntegrationService?.config?.aws;
const isServiceEnabledInPersistedConfig =
Boolean(integrationConfig?.logs?.enabled) ||
Boolean(integrationConfig?.metrics?.enabled);
Boolean(awsConfig?.logs?.enabled) || Boolean(awsConfig?.metrics?.enabled);
const serviceDetailsId = serviceDetailsData?.id;
const isLogsSupported = serviceDetailsData?.supportedSignals?.logs || false;
const isMetricsSupported =
serviceDetailsData?.supportedSignals?.metrics || false;
const {
control,
@@ -158,31 +77,43 @@ function ServiceDetails({
watch,
formState: { isDirty },
} = useForm<ServiceConfigFormValues>({
defaultValues: getInitialFormValues(type, serviceDetailsData),
defaultValues: {
logsEnabled: awsConfig?.logs?.enabled || false,
metricsEnabled: awsConfig?.metrics?.enabled || false,
s3BucketsByRegion: awsConfig?.logs?.s3Buckets || {},
},
});
const resetToConfig = useCallback((): void => {
reset(getInitialFormValues(type, serviceDetailsData));
}, [reset, serviceDetailsData, type]);
const resetToAwsConfig = useCallback((): void => {
reset({
logsEnabled: awsConfig?.logs?.enabled || false,
metricsEnabled: awsConfig?.metrics?.enabled || false,
s3BucketsByRegion: awsConfig?.logs?.s3Buckets || {},
});
}, [awsConfig, reset]);
// Ensure form state does not leak across service switches while new details load.
useEffect(() => {
reset(EMPTY_FORM_VALUES);
reset({
logsEnabled: false,
metricsEnabled: false,
s3BucketsByRegion: {},
});
}, [reset, serviceId]);
useEffect(() => {
resetToConfig();
}, [resetToConfig, serviceDetailsId]);
resetToAwsConfig();
}, [resetToAwsConfig, serviceDetailsId]);
// log telemetry event on visiting details of a service.
useEffect(() => {
if (serviceId) {
logEvent(`${type} Integration: Service viewed`, {
logEvent('AWS Integration: Service viewed', {
cloudAccountId,
serviceId,
});
}
}, [cloudAccountId, serviceId, type]);
}, [cloudAccountId, serviceId]);
const { mutate: updateService, isLoading: isUpdatingServiceConfig } =
useUpdateService();
@@ -190,8 +121,8 @@ function ServiceDetails({
const queryClient = useQueryClient();
const handleDiscard = useCallback((): void => {
resetToConfig();
}, [resetToConfig]);
resetToAwsConfig();
}, [resetToAwsConfig]);
const onSubmit = useCallback(
async (values: ServiceConfigFormValues): Promise<void> => {
@@ -210,25 +141,25 @@ function ServiceDetails({
return;
}
const serviceConfigPayload = getServiceConfigPayload({
type,
serviceId,
logsEnabled,
metricsEnabled,
isLogsSupported,
isMetricsSupported,
s3BucketsByRegion: normalizedS3BucketsByRegion,
});
updateService(
{
pathParams: {
cloudProvider: type,
cloudProvider: INTEGRATION_TYPES.AWS,
id: cloudAccountId,
serviceId,
},
data: {
config: serviceConfigPayload,
config: {
aws: {
logs: {
enabled: logsEnabled,
s3Buckets: normalizedS3BucketsByRegion,
},
metrics: {
enabled: metricsEnabled,
},
},
},
},
},
{
@@ -239,7 +170,7 @@ function ServiceDetails({
const servicesListQueryKey = getListServicesMetadataQueryKey(
{
cloudProvider: type,
cloudProvider: INTEGRATION_TYPES.AWS,
},
{
cloud_integration_id: cloudAccountId,
@@ -272,7 +203,7 @@ function ServiceDetails({
invalidateGetService(
queryClient,
{
cloudProvider: type,
cloudProvider: INTEGRATION_TYPES.AWS,
serviceId,
},
{
@@ -283,14 +214,14 @@ function ServiceDetails({
invalidateListServicesMetadata(
queryClient,
{
cloudProvider: type,
cloudProvider: INTEGRATION_TYPES.AWS,
},
{
cloud_integration_id: cloudAccountId,
},
);
logEvent(`${type} Integration: Service settings saved`, {
logEvent('AWS Integration: Service settings saved', {
cloudAccountId,
serviceId,
logsEnabled,
@@ -310,16 +241,7 @@ function ServiceDetails({
console.error('Form submission failed:', error);
}
},
[
serviceId,
cloudAccountId,
updateService,
queryClient,
reset,
type,
isLogsSupported,
isMetricsSupported,
],
[serviceId, cloudAccountId, updateService, queryClient, reset],
);
if (isServiceDetailsLoading) {
@@ -340,6 +262,10 @@ function ServiceDetails({
const logsEnabled = watch('logsEnabled');
const s3BucketsByRegion = watch('s3BucketsByRegion');
const isLogsSupported = serviceDetailsData?.supportedSignals?.logs || false;
const isMetricsSupported =
serviceDetailsData?.supportedSignals?.metrics || false;
const hasUnsavedChanges = isDirty;
const isS3SyncBucketsMissing =

View File

@@ -1,7 +1,7 @@
import cx from 'classnames';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { Service } from './AmazonWebServices/types';
import { Service } from './types';
function ServiceItem({
service,

View File

@@ -4,20 +4,15 @@ import { Skeleton } from 'antd';
import { useListServicesMetadata } from 'api/generated/services/cloudintegration';
import type { CloudintegrationtypesServiceMetadataDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { IntegrationType } from 'container/Integrations/types';
import useUrlQuery from 'hooks/useUrlQuery';
import emptyStateIconUrl from '@/assets/Icons/emptyState.svg';
interface ServicesListProps {
cloudAccountId: string;
type: IntegrationType;
}
function ServicesList({
cloudAccountId,
type,
}: ServicesListProps): JSX.Element {
function ServicesList({ cloudAccountId }: ServicesListProps): JSX.Element {
const urlQuery = useUrlQuery();
const navigate = useNavigate();
const hasValidCloudAccountId = Boolean(cloudAccountId);
@@ -27,7 +22,7 @@ function ServicesList({
const { data: servicesMetadata, isLoading } = useListServicesMetadata(
{
cloudProvider: type,
cloudProvider: 'aws',
},
serviceQueryParams,
);

View File

@@ -0,0 +1,29 @@
import useUrlQuery from 'hooks/useUrlQuery';
import HeroSection from './HeroSection/HeroSection';
import ServiceDetails from './ServiceDetails/ServiceDetails';
import ServicesList from './ServicesList';
import './ServicesTabs.style.scss';
function ServicesTabs(): JSX.Element {
const urlQuery = useUrlQuery();
const cloudAccountId = urlQuery.get('cloudAccountId') || '';
return (
<div className="services-tabs">
<HeroSection />
<div className="services-section">
<div className="services-section__sidebar">
<ServicesList cloudAccountId={cloudAccountId} />
</div>
<div className="services-section__content">
<ServiceDetails />
</div>
</div>
</div>
);
}
export default ServicesTabs;

View File

@@ -29,7 +29,7 @@ jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: (): JSX.Element => <div data-testid="markdown-renderer" />,
}));
jest.mock(
'container/Integrations/CloudIntegration/ServiceDashboards/ServiceDashboards',
'container/Integrations/CloudIntegration/AmazonWebServices/ServiceDashboards/ServiceDashboards',
() => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="service-dashboards" />,

View File

@@ -1,5 +1,4 @@
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
import { IntegrationType } from 'container/Integrations/types';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import ServiceDetails from '../ServiceDetails/ServiceDetails';
@@ -12,11 +11,10 @@ import { accountsResponse } from './mockData';
const renderServiceDetails = (
_initialConfigLogsS3Buckets: Record<string, string[]> = {},
_serviceId = 's3sync',
type: IntegrationType = IntegrationType.AWS_SERVICES,
): RenderResult =>
render(
<MockQueryClientProvider>
<ServiceDetails type={type} />
<ServiceDetails />
</MockQueryClientProvider>,
);

View File

@@ -0,0 +1,25 @@
import { CloudintegrationtypesAccountDTO } from 'api/generated/services/sigNoz.schemas';
import { CloudAccount } from './types';
export function mapAccountDtoToAwsCloudAccount(
account: CloudintegrationtypesAccountDTO,
): CloudAccount | null {
if (!account.providerAccountId) {
return null;
}
return {
id: account.id,
cloud_account_id: account.id,
config: {
regions: account.config?.aws?.regions ?? [],
},
status: {
integration: {
last_heartbeat_ts_ms: account.agentReport?.timestampMillis ?? 0,
},
},
providerAccountId: account.providerAccountId,
};
}

View File

@@ -1,356 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { Button, Callout, DrawerWrapper, Tabs } from '@signozhq/ui';
import { Form, Select, Spin } from 'antd';
import { useGetAccount } from 'api/generated/services/cloudintegration';
import { CloudintegrationtypesAccountDTO } from 'api/generated/services/sigNoz.schemas';
import CodeBlock from 'components/CodeBlock/CodeBlock';
import {
AZURE_REGIONS,
INTEGRATION_TYPES,
} from 'container/Integrations/constants';
import {
IntegrationModalProps,
ModalStateEnum,
} from 'container/Integrations/HeroSection/types';
import { LoaderCircle, SquareArrowOutUpRight } from 'lucide-react';
import { popupContainer } from 'utils/selectPopupContainer';
import { useIntegrationModal } from '../../../../../hooks/integration/azure/useIntegrationModal';
import RenderConnectionFields from '../../AmazonWebServices/RegionForm/RenderConnectionParams';
import '../../AmazonWebServices/AddNewAccount/CloudAccountSetupModal.style.scss';
const AZURE_CLI_DESC =
'Paste the following command if you have Azure CLI setup locally on your machine or use BASH CloudShell on Azure portal with above mentioned permissions.';
const AZURE_POWERSHELL_DESC =
'Paste the following command in PowerShell CloudShell on Azure portal, you can switch to PowerShell on Azure portal.';
function CloudAccountSetupModal({
onClose,
}: IntegrationModalProps): JSX.Element {
const {
form,
modalState,
isLoading,
accountId,
connectionCommands,
handleSubmit,
handleClose,
connectionParams,
isConnectionParamsLoading,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
} = useIntegrationModal({ onClose });
const startTimeRef = useRef(Date.now());
const refetchInterval = 10 * 1000;
const errorTimeout = 10 * 60 * 1000;
const [isHowItWorksOpen, setIsHowItWorksOpen] = useState(true);
const [activeTab, setActiveTab] = useState('cli');
useGetAccount(
{
cloudProvider: INTEGRATION_TYPES.AZURE,
id: accountId ?? '',
},
{
query: {
enabled: Boolean(accountId) && modalState === ModalStateEnum.WAITING,
refetchInterval,
select: (response): CloudintegrationtypesAccountDTO => response.data,
onSuccess: (account) => {
const isConnected =
Boolean(account.providerAccountId) && account.removedAt === null;
if (isConnected) {
handleConnectionSuccess({
cloudAccountId: account.providerAccountId ?? account.id,
status: account.agentReport,
});
} else if (Date.now() - startTimeRef.current >= errorTimeout) {
handleConnectionTimeout({ id: accountId });
}
},
onError: () => {
handleConnectionError();
},
},
},
);
const renderAlert = useCallback((): JSX.Element | null => {
if (modalState === ModalStateEnum.WAITING) {
return (
<div className="cloud-account-setup-form__alert">
<Callout
title={
<div className="cloud-account-setup-form__alert-message">
<Spin
indicator={
<LoaderCircle
size={14}
className="anticon anticon-loading anticon-spin ant-spin-dot"
/>
}
/>
Waiting for Azure account connection, retrying in{' '}
<span className="retry-time">10</span> secs...
</div>
}
type="info"
showIcon={false}
/>
</div>
);
}
if (modalState === ModalStateEnum.ERROR) {
return (
<div className="cloud-account-setup-form__alert">
<Callout
title={
<div className="cloud-account-setup-form__alert-message">
We couldn&apos;t establish a connection to your Azure account. Please
try again
</div>
}
type="error"
/>
</div>
);
}
return null;
}, [modalState]);
const footer = (
<div className="cloud-account-setup-modal__footer">
{modalState === ModalStateEnum.FORM && (
<Button
variant="solid"
color="primary"
prefix={<SquareArrowOutUpRight size={17} color={Color.BG_VANILLA_100} />}
onClick={handleSubmit}
loading={isLoading}
>
Generate Azure Setup Commands
</Button>
)}
</div>
);
return (
<DrawerWrapper
open={true}
className="cloud-account-setup-modal"
onOpenChange={(open): void => {
if (!open) {
handleClose();
}
}}
direction="right"
showCloseButton
title="Add Azure Account"
width="wide"
footer={footer}
>
<div className="cloud-account-setup-modal__content">
<div className="cloud-account-setup-prerequisites">
<div className="cloud-account-setup-prerequisites__title">
Prerequisites
</div>
<ul className="cloud-account-setup-prerequisites__list">
<li className="cloud-account-setup-prerequisites__list-item">
<span className="cloud-account-setup-prerequisites__list-item-bullet">
</span>{' '}
<span className="cloud-account-setup-prerequisites__list-item-text">
Ensure that you&apos;re logged in to the Azure workspace which you want
to monitor.
</span>
</li>
<li className="cloud-account-setup-prerequisites__list-item">
<span className="cloud-account-setup-prerequisites__list-item-bullet">
</span>{' '}
<span className="cloud-account-setup-prerequisites__list-item-text">
Ensure that you either have the{' '}
<span className="cloud-account-setup-prerequisites__list-item-highlight">
Owner
</span>{' '}
role OR
</span>
</li>
<li className="cloud-account-setup-prerequisites__list-item">
<span className="cloud-account-setup-prerequisites__list-item-bullet">
</span>{' '}
<span className="cloud-account-setup-prerequisites__list-item-text">
Both the{' '}
<span className="cloud-account-setup-prerequisites__list-item-highlight">
Contributor
</span>{' '}
and{' '}
<span className="cloud-account-setup-prerequisites__list-item-highlight">
user access admin
</span>{' '}
roles
</span>
</li>
</ul>
</div>
<div className="cloud-account-setup-how-it-works-accordion">
<div
className={`cloud-account-setup-how-it-works-accordion__title ${
isHowItWorksOpen ? 'open' : ''
}`}
>
<Button
variant="link"
color="secondary"
onClick={(): void => setIsHowItWorksOpen(!isHowItWorksOpen)}
prefix={isHowItWorksOpen ? <ChevronDown /> : <ChevronRight />}
/>
<span className="cloud-account-setup-how-it-works-accordion__title-text">
How it works?
</span>
</div>
{isHowItWorksOpen && (
<div className="cloud-account-setup-how-it-works-accordion__description">
<div className="cloud-account-setup-how-it-works-accordion__description-item">
SigNoz will create new resource-group to manage the resources required
for this integration. The following steps will create a User-Assigned
Managed Identity with the necessary permissions and follows the
Principle of Least Privilege.
</div>
<div className="cloud-account-setup-how-it-works__description-item">
Once the Integration template is deployed, you can enable the services
you want to monitor right here in Signoz dashboard.
</div>
</div>
)}
</div>
<Form
form={form}
className="cloud-account-setup-form"
layout="vertical"
initialValues={{ resourceGroups: [] }}
>
<div className="cloud-account-setup-form__content">
<div className="cloud-account-setup-form__form-group">
<div className="cloud-account-setup-form__title">
Where should we deploy the SigNoz collector resources?
</div>
<div className="cloud-account-setup-form__description">
Choose the Azure region for deployment.
</div>
<Form.Item
name="region"
rules={[{ required: true, message: 'Please select a region' }]}
className="cloud-account-setup-form__form-item"
>
<Select
placeholder="e.g. East US"
options={AZURE_REGIONS.map((region) => ({
label: `${region.label} (${region.value})`,
value: region.value,
}))}
getPopupContainer={popupContainer}
disabled={modalState === ModalStateEnum.WAITING}
/>
</Form.Item>
</div>
<div className="cloud-account-setup-form__form-group">
<div className="cloud-account-setup-form__title">
Which resource groups do you want to monitor?
</div>
<div className="cloud-account-setup-form__description">
Add one or more Azure resource group names.
</div>
<Form.Item
name="resourceGroups"
rules={[
{
required: true,
type: 'array',
min: 1,
message: 'Please add at least one resource group',
},
]}
className="cloud-account-setup-form__form-item"
>
<Select
mode="tags"
placeholder="e.g. prod-platform-rg"
tokenSeparators={[',']}
disabled={modalState === ModalStateEnum.WAITING}
/>
</Form.Item>
</div>
<RenderConnectionFields
isConnectionParamsLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
isFormDisabled={modalState === ModalStateEnum.WAITING}
/>
{connectionCommands && (
<div className="cloud-account-setup-form__code-block-tabs-container">
<div className="cloud-account-setup-form__code-block-tabs-header">
<div className="cloud-account-setup-form__code-block-tabs-header-title">
Deploy Agent
</div>
<div className="cloud-account-setup-form__code-block-tabs-header-description">
{activeTab === 'cli' ? AZURE_CLI_DESC : AZURE_POWERSHELL_DESC}
</div>
</div>
<Tabs
className="cloud-account-setup-form__code-block-tabs"
items={[
{
key: 'cli',
label: 'CLI',
children: <CodeBlock code={connectionCommands?.cliCommand || ''} />,
},
{
key: 'powershell',
label: 'PowerShell',
children: (
<CodeBlock
code={connectionCommands?.cloudPowerShellCommand || ''}
/>
),
},
]}
value={activeTab}
onChange={(key): void => setActiveTab(key)}
variant="primary"
/>
</div>
)}
{renderAlert()}
{modalState === ModalStateEnum.WAITING && (
<div className="cloud-account-setup-status-message">
After running the command, return here and wait for automatic connection
detection.
</div>
)}
</div>
</Form>
</div>
</DrawerWrapper>
);
}
export default CloudAccountSetupModal;

View File

@@ -1,150 +0,0 @@
import { Dispatch, SetStateAction, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import { Button, DrawerWrapper } from '@signozhq/ui';
import { Form, Select } from 'antd';
import { invalidateListAccounts } from 'api/generated/services/cloudintegration';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { CloudAccount } from 'container/Integrations/types';
import { Save } from 'lucide-react';
import { useAccountSettingsModal } from '../../../../../hooks/integration/azure/useAccountSettingsModal';
import RemoveIntegrationAccount from '../../RemoveAccount/RemoveIntegrationAccount';
import '../../AmazonWebServices/EditAccount/AccountSettingsModal.style.scss';
interface AccountSettingsModalProps {
onClose: () => void;
account: CloudAccount;
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
}
function AccountSettingsModal({
onClose,
account,
setActiveAccount,
}: AccountSettingsModalProps): JSX.Element {
const {
form,
isLoading,
resourceGroups,
isSaveDisabled,
setResourceGroups,
handleSubmit,
handleClose,
} = useAccountSettingsModal({ onClose, account, setActiveAccount });
const queryClient = useQueryClient();
const azureConfig = useMemo(
() => ('deployment_region' in account.config ? account.config : null),
[account.config],
);
return (
<DrawerWrapper
open={true}
className="account-settings-modal"
title="Account Settings"
direction="right"
showCloseButton
onOpenChange={(open): void => {
if (!open) {
handleClose();
}
}}
width="wide"
footer={
<div className="account-settings-modal__footer">
<RemoveIntegrationAccount
accountId={account?.id}
onRemoveIntegrationAccountSuccess={(): void => {
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.AZURE,
});
setActiveAccount(null);
handleClose();
}}
cloudProvider={INTEGRATION_TYPES.AZURE}
/>
<Button
variant="solid"
color="secondary"
disabled={isSaveDisabled}
onClick={handleSubmit}
loading={isLoading}
prefix={<Save size={14} />}
>
Update Changes
</Button>
</div>
}
>
<Form
form={form}
layout="vertical"
initialValues={{
resourceGroups: azureConfig?.resource_groups || [],
}}
>
<div className="account-settings-modal__body">
<div className="account-settings-modal__body-account-info">
<div className="account-settings-modal__body-account-info-connected-account-details">
<div className="account-settings-modal__body-account-info-connected-account-details-title">
Connected Account details
</div>
<div className="account-settings-modal__body-account-info-connected-account-details-account-id">
Azure Subscription:{' '}
<span className="account-settings-modal__body-account-info-connected-account-details-account-id-account-id">
{account?.providerAccountId}
</span>
</div>
</div>
</div>
{azureConfig?.deployment_region && (
<div className="account-settings-modal__body-region-selector">
<div className="account-settings-modal__body-region-selector-title">
Deployment region
</div>
<div className="account-settings-modal__body-region-selector-description">
{azureConfig.deployment_region}
</div>
</div>
)}
<div className="account-settings-modal__body-region-selector">
<div className="account-settings-modal__body-region-selector-title">
Resource groups
</div>
<div className="account-settings-modal__body-region-selector-description">
Update the resource groups that should be monitored.
</div>
<Form.Item
name="resourceGroups"
rules={[
{
required: true,
type: 'array',
min: 1,
message: 'Please add at least one resource group',
},
]}
>
<Select
mode="tags"
value={resourceGroups}
onChange={(values): void => {
setResourceGroups(values);
form.setFieldValue('resourceGroups', values);
}}
/>
</Form.Item>
</div>
</div>
</Form>
</DrawerWrapper>
);
}
export default AccountSettingsModal;

View File

@@ -1,15 +1,16 @@
import { IntegrationType } from 'container/Integrations/types';
import AWSTabs from './AmazonWebServices/ServicesTabs';
import Header from './Header/Header';
import ServicesTabs from './ServiceTabs/ServicesTabs';
import './CloudIntegration.styles.scss';
const CloudIntegration = ({ type }: { type: IntegrationType }): JSX.Element => {
return (
<div className="cloud-integration-container">
<Header type={type} />
<ServicesTabs type={type} />
<Header title={type} />
{type === IntegrationType.AWS_SERVICES && <AWSTabs />}
</div>
);
};

View File

@@ -7,7 +7,7 @@ import { Blocks, LifeBuoy } from 'lucide-react';
import './Header.styles.scss';
function Header({ type }: { type: IntegrationType }): JSX.Element {
function Header({ title }: { title: IntegrationType }): JSX.Element {
return (
<div className="cloud-header">
<div className="cloud-header__navigation">
@@ -25,30 +25,27 @@ function Header({ type }: { type: IntegrationType }): JSX.Element {
),
},
{
title: <div className="cloud-header__breadcrumb-title">{type}</div>,
title: <div className="cloud-header__breadcrumb-title">{title}</div>,
},
]}
/>
</div>
{type === IntegrationType.AWS_SERVICES && (
<div className="cloud-header__actions">
<Button
variant="solid"
size="sm"
color="secondary"
onClick={(): void => {
window.open(
'https://signoz.io/blog/native-aws-integrations-with-autodiscovery/',
'_blank',
);
}}
prefix={<LifeBuoy size={12} />}
>
Get Help
</Button>
</div>
)}
<div className="cloud-header__actions">
<Button
variant="solid"
size="sm"
color="secondary"
onClick={(): void => {
window.open(
'https://signoz.io/blog/native-aws-integrations-with-autodiscovery/',
'_blank',
);
}}
prefix={<LifeBuoy size={12} />}
>
Get Help
</Button>
</div>
</div>
);
}

View File

@@ -1,30 +0,0 @@
import { IntegrationType } from 'container/Integrations/types';
import useUrlQuery from 'hooks/useUrlQuery';
import HeroSection from '../../HeroSection/HeroSection';
import ServiceDetails from '../AmazonWebServices/ServiceDetails/ServiceDetails';
import ServicesList from '../ServicesList';
import './ServicesTabs.style.scss';
function ServicesTabs({ type }: { type: IntegrationType }): JSX.Element {
const urlQuery = useUrlQuery();
const cloudAccountId = urlQuery.get('cloudAccountId') || '';
return (
<div className="services-tabs">
<HeroSection type={type} />
<div className="services-section">
<div className="services-section__sidebar">
<ServicesList cloudAccountId={cloudAccountId} type={type} />
</div>
<div className="services-section__content">
<ServiceDetails type={type} />
</div>
</div>
</div>
);
}
export default ServicesTabs;

View File

@@ -1,49 +0,0 @@
import { CloudintegrationtypesAccountDTO } from 'api/generated/services/sigNoz.schemas';
import { CloudAccount as IntegrationCloudAccount } from 'container/Integrations/types';
import { CloudAccount as AwsCloudAccount } from './AmazonWebServices/types';
export function mapAccountDtoToAwsCloudAccount(
account: CloudintegrationtypesAccountDTO,
): AwsCloudAccount | null {
if (!account.providerAccountId) {
return null;
}
return {
id: account.id,
cloud_account_id: account.id,
config: {
regions: account.config?.aws?.regions ?? [],
},
status: {
integration: {
last_heartbeat_ts_ms: account.agentReport?.timestampMillis ?? 0,
},
},
providerAccountId: account.providerAccountId,
};
}
export function mapAccountDtoToAzureCloudAccount(
account: CloudintegrationtypesAccountDTO,
): IntegrationCloudAccount | null {
if (!account.providerAccountId) {
return null;
}
return {
id: account.id,
cloud_account_id: account.id,
config: {
deployment_region: account.config?.azure?.deploymentRegion ?? '',
resource_groups: account.config?.azure?.resourceGroups ?? [],
},
status: {
integration: {
last_heartbeat_ts_ms: account.agentReport?.timestampMillis ?? 0,
},
},
providerAccountId: account.providerAccountId,
};
}

View File

@@ -1,32 +1,5 @@
import { ONE_CLICK_INTEGRATIONS } from '../constants';
import { IntegrationType } from '../types';
export const getAccountById = <T extends { cloud_account_id: string }>(
accounts: T[],
accountId: string,
): T | null =>
accounts.find((account) => account.cloud_account_id === accountId) || null;
interface IntegrationMetadata {
title: string;
description: string;
logo: string;
}
export const getIntegrationMetadata = (
type: IntegrationType,
): IntegrationMetadata => {
const integration = ONE_CLICK_INTEGRATIONS.find(
(integration) => integration.id === type,
);
if (!integration) {
return { title: '', description: '', logo: '' };
}
return {
title: integration.title,
description: integration.description,
logo: integration.icon,
};
};

View File

@@ -1,33 +0,0 @@
import { IntegrationType } from 'container/Integrations/types';
import AccountActions from '../CloudIntegration/AmazonWebServices/AccountActions/AccountActions';
import { getIntegrationMetadata } from '../CloudIntegration/utils';
import './HeroSection.style.scss';
function HeroSection({ type }: { type: IntegrationType }): JSX.Element {
const {
title,
description,
logo: integrationLogo,
} = getIntegrationMetadata(type);
return (
<div className="hero-section">
<div className="hero-section__details">
<div className="hero-section__details-header">
<div className="hero-section__icon">
<img src={integrationLogo} alt={type} />
</div>
<div className="hero-section__details-title">{title}</div>
</div>
<div className="hero-section__details-description">{description}</div>
</div>
<AccountActions type={type} />
</div>
);
}
export default HeroSection;

View File

@@ -9,6 +9,53 @@
flex-direction: column;
gap: 16px;
.error-container {
display: flex;
border-radius: 6px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
align-items: center;
justify-content: center;
flex-direction: column;
.error-content {
display: flex;
flex-direction: column;
justify-content: center;
height: 300px;
gap: 15px;
.error-btns {
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
.retry-btn {
display: flex;
align-items: center;
}
.contact-support {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
.text {
color: var(--callout-primary-description);
font-weight: 500;
}
}
}
.error-state-svg {
height: 40px;
width: 40px;
}
}
}
.loading-integration-details {
display: flex;
flex-direction: column;
@@ -280,36 +327,6 @@
}
}
}
.error-container {
display: flex;
border-radius: 6px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
align-items: center;
justify-content: center;
flex-direction: column;
.error-content {
display: flex;
flex-direction: column;
justify-content: center;
height: 300px;
gap: 15px;
.error-btns {
display: flex;
flex-direction: row;
gap: 12px;
align-items: center;
}
.error-state-svg {
height: 40px;
width: 40px;
}
}
}
}
.remove-integration-modal {

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { Color } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui';
import { Flex, Skeleton, Typography } from 'antd';
import ROUTES from 'constants/routes';
@@ -54,19 +55,8 @@ function IntegrationDetailPage(): JSX.Element {
),
);
if (
integrationId === INTEGRATION_TYPES.AWS ||
integrationId === INTEGRATION_TYPES.AZURE
) {
return (
<CloudIntegration
type={
integrationId === INTEGRATION_TYPES.AWS
? IntegrationType.AWS_SERVICES
: IntegrationType.AZURE_SERVICES
}
/>
);
if (integrationId === INTEGRATION_TYPES.AWS) {
return <CloudIntegration type={IntegrationType.AWS_SERVICES} />;
}
return (
@@ -95,20 +85,20 @@ function IntegrationDetailPage(): JSX.Element {
<div className="error-btns">
<Button
variant="solid"
color="secondary"
color="primary"
onClick={(): Promise<any> => refetch()}
prefix={<RotateCw size={14} />}
>
Retry
</Button>
<Button
variant="solid"
color="secondary"
<div
className="contact-support"
onClick={(): void => handleContactSupport(isCloudUserVal)}
suffix={<MoveUpRight size={12} />}
>
Contact Support
</Button>
<Typography.Link className="text">Contact Support </Typography.Link>
<MoveUpRight size={14} color={Color.BG_ROBIN_400} />
</div>
</div>
</div>
</div>

View File

@@ -22,7 +22,6 @@ function OneClickIntegrations(props: OneClickIntegrationsProps): JSX.Element {
if (!query) {
return ONE_CLICK_INTEGRATIONS;
}
return ONE_CLICK_INTEGRATIONS.filter(
(integration) =>
integration.title.toLowerCase().includes(query) ||

View File

@@ -14,8 +14,8 @@ export const INTEGRATION_TELEMETRY_EVENTS = {
'Integrations Detail Page: Clicked remove Integration button for integration',
INTEGRATIONS_DETAIL_CONFIGURE_INSTRUCTION:
'Integrations Detail Page: Navigated to configure an integration',
INTEGRATION_ACCOUNT_REMOVED:
'Integration Detail page: Clicked remove Integration button for integration',
AWS_INTEGRATION_ACCOUNT_REMOVED:
'AWS Integration Detail page: Clicked remove Integration button for integration',
};
export const INTEGRATION_TYPES = {
@@ -53,7 +53,7 @@ export const AZURE_INTEGRATION = {
is_new: true,
};
export const ONE_CLICK_INTEGRATIONS = [AWS_INTEGRATION, AZURE_INTEGRATION];
export const ONE_CLICK_INTEGRATIONS = [AWS_INTEGRATION];
export const AZURE_REGIONS: AzureRegion[] = [
{
@@ -81,7 +81,6 @@ export const AZURE_REGIONS: AzureRegion[] = [
{ label: 'Central India', value: 'centralindia', geography: 'India' },
{ label: 'Central US', value: 'centralus', geography: 'United States' },
{ label: 'Chile Central', value: 'chilecentral', geography: 'Chile' },
{ label: 'Denmark East', value: 'denmarkeast', geography: 'Denmark' },
{ label: 'East Asia', value: 'eastasia', geography: 'Asia Pacific' },
{ label: 'East US', value: 'eastus', geography: 'United States' },
{ label: 'East US 2', value: 'eastus2', geography: 'United States' },

View File

@@ -4,8 +4,8 @@ import {
} from './CloudIntegration/AmazonWebServices/types';
export enum IntegrationType {
AWS_SERVICES = 'aws',
AZURE_SERVICES = 'azure',
AWS_SERVICES = 'aws-services',
AZURE_SERVICES = 'azure-services',
}
interface LogField {
@@ -89,7 +89,6 @@ export interface CloudAccount {
cloud_account_id: string;
config: AzureCloudAccountConfig | AWSCloudAccountConfig;
status: AccountStatus | IServiceStatus;
providerAccountId: string;
}
export interface AzureCloudAccountConfig {

View File

@@ -196,7 +196,6 @@ export const getUplotClickData = ({
coord: { x: number; y: number };
record: { queryName: string; filters: FilterData[] };
label: string | React.ReactNode;
seriesColor?: string;
} | null => {
if (!queryData?.queryName || !metric) {
return null;
@@ -209,8 +208,6 @@ export const getUplotClickData = ({
// Generate label from focusedSeries data
let label: string | React.ReactNode = '';
const seriesColor = focusedSeries?.color;
if (focusedSeries && focusedSeries.seriesName) {
label = (
<span style={{ color: focusedSeries.color }}>
@@ -226,7 +223,6 @@ export const getUplotClickData = ({
},
record,
label,
seriesColor,
};
};
@@ -241,7 +237,6 @@ export const getPieChartClickData = (
queryName: string;
filters: FilterData[];
label: string | React.ReactNode;
seriesColor?: string;
} | null => {
const { metric, queryName } = arc.data.record;
if (!queryName || !metric) {
@@ -253,7 +248,6 @@ export const getPieChartClickData = (
queryName,
filters: getFiltersFromMetric(metric), // TODO: add where clause query as well.
label,
seriesColor: arc.data.color,
};
};

View File

@@ -22,7 +22,6 @@ export interface AggregateData {
endTime: number;
};
label?: string | React.ReactNode;
seriesColor?: string;
}
const useAggregateDrilldown = ({

View File

@@ -228,13 +228,7 @@ const useBaseAggregateOptions = ({
return (
<ContextMenu.Item
key={key}
icon={
isLoading ? (
<LoadingOutlined spin />
) : (
<span style={{ color: aggregateData?.seriesColor }}>{icon}</span>
)
}
icon={isLoading ? <LoadingOutlined spin /> : icon}
onClick={(): void => onClick()}
disabled={isLoading}
>

View File

@@ -7,13 +7,6 @@ import {
GetIntegrationStatusProps,
} from 'types/api/integrations/types';
export function isOneClickIntegration(integrationId: string): boolean {
return (
integrationId === INTEGRATION_TYPES.AWS ||
integrationId === INTEGRATION_TYPES.AZURE
);
}
export const useGetIntegrationStatus = ({
integrationId,
}: GetIntegrationPayloadProps): UseQueryResult<
@@ -27,5 +20,5 @@ export const useGetIntegrationStatus = ({
enabled:
!!integrationId &&
integrationId !== '' &&
!isOneClickIntegration(integrationId),
integrationId !== INTEGRATION_TYPES.AWS,
});

View File

@@ -20,11 +20,11 @@ import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesPostableAccountDTO,
} from 'api/generated/services/sigNoz.schemas';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import {
ActiveViewEnum,
ModalStateEnum,
} from 'container/Integrations/HeroSection/types';
} from 'container/Integrations/CloudIntegration/AmazonWebServices/HeroSection/types';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import useAxiosError from 'hooks/useAxiosError';
import { regions } from 'utils/regions';

View File

@@ -1,142 +0,0 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { toast } from '@signozhq/ui';
import { Form } from 'antd';
import { FormInstance } from 'antd/lib';
import { useUpdateAccount } from 'api/generated/services/cloudintegration';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { CloudAccount } from 'container/Integrations/types';
import { isEqual } from 'lodash-es';
import logEvent from '../../../api/common/logEvent';
interface UseAccountSettingsModalProps {
onClose: () => void;
account: CloudAccount;
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
}
interface UseAccountSettingsModal {
form: FormInstance;
isLoading: boolean;
resourceGroups: string[];
isSaveDisabled: boolean;
setResourceGroups: Dispatch<SetStateAction<string[]>>;
handleSubmit: () => Promise<void>;
handleClose: () => void;
}
export function useAccountSettingsModal({
onClose,
account,
setActiveAccount,
}: UseAccountSettingsModalProps): UseAccountSettingsModal {
const [form] = Form.useForm();
const { mutate: updateAccount, isLoading } = useUpdateAccount();
const accountConfig = useMemo(
() => ('deployment_region' in account.config ? account.config : null),
[account.config],
);
const [resourceGroups, setResourceGroups] = useState<string[]>(
accountConfig?.resource_groups || [],
);
useEffect(() => {
if (!accountConfig) {
return;
}
form.setFieldsValue({
region: accountConfig.deployment_region,
resourceGroups: accountConfig.resource_groups,
});
setResourceGroups(accountConfig.resource_groups);
}, [accountConfig, form]);
const handleSubmit = useCallback(async (): Promise<void> => {
try {
const values = await form.validateFields();
updateAccount(
{
pathParams: {
cloudProvider: INTEGRATION_TYPES.AZURE,
id: account?.id || '',
},
data: {
config: {
azure: {
resourceGroups: values.resourceGroups || [],
},
},
},
},
{
onSuccess: () => {
const nextConfig = {
deployment_region: accountConfig?.deployment_region || '',
resource_groups: values.resourceGroups || [],
};
setActiveAccount({
...account,
config: nextConfig,
});
onClose();
toast.success('Account settings updated successfully', {
position: 'bottom-right',
});
logEvent('Azure Integration: Account settings updated', {
cloudAccountId: account.cloud_account_id,
deploymentRegion: nextConfig.deployment_region,
resourceGroups: nextConfig.resource_groups,
});
},
onError: (error) => {
toast.error('Failed to update account settings', {
description: error?.message,
position: 'bottom-right',
});
},
},
);
} catch (error) {
console.error('Form submission failed:', error);
}
}, [form, updateAccount, account, setActiveAccount, onClose]);
const isSaveDisabled = useMemo(() => {
if (!accountConfig) {
return true;
}
const formResourceGroups = resourceGroups || [];
return isEqual(
[...formResourceGroups].sort(),
[...accountConfig.resource_groups].sort(),
);
}, [accountConfig, resourceGroups, form]);
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
return {
form,
isLoading,
resourceGroups,
isSaveDisabled,
setResourceGroups,
handleSubmit,
handleClose,
};
}

View File

@@ -1,188 +0,0 @@
import { Dispatch, SetStateAction, useCallback, useState } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui';
import { Form, FormInstance } from 'antd';
import {
CreateAccountMutationResult,
GetConnectionCredentialsQueryResult,
invalidateListAccounts,
useCreateAccount,
useGetConnectionCredentials,
} from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesPostableAccountDTO,
} from 'api/generated/services/sigNoz.schemas';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
import useAxiosError from 'hooks/useAxiosError';
import logEvent from '../../../api/common/logEvent';
interface UseIntegrationModalProps {
onClose: () => void;
}
interface UseAzureIntegrationModal {
form: FormInstance;
modalState: ModalStateEnum;
isLoading: boolean;
accountId?: string;
connectionCommands: {
cliCommand: string;
cloudPowerShellCommand: string;
} | null;
setModalState: Dispatch<SetStateAction<ModalStateEnum>>;
handleSubmit: () => Promise<void>;
handleClose: () => void;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
handleConnectionSuccess: (payload: {
cloudAccountId: string;
status?: unknown;
}) => void;
handleConnectionTimeout: (payload: { id?: string }) => void;
handleConnectionError: () => void;
}
export function useIntegrationModal({
onClose,
}: UseIntegrationModalProps): UseAzureIntegrationModal {
const queryClient = useQueryClient();
const [form] = Form.useForm();
const [modalState, setModalState] = useState<ModalStateEnum>(
ModalStateEnum.FORM,
);
const [isLoading, setIsLoading] = useState(false);
const [accountId, setAccountId] = useState<string | undefined>(undefined);
const [connectionCommands, setConnectionCommands] = useState<{
cliCommand: string;
cloudPowerShellCommand: string;
} | null>(null);
const handleClose = useCallback((): void => {
setModalState(ModalStateEnum.FORM);
setConnectionCommands(null);
onClose();
}, [onClose]);
const handleConnectionSuccess = useCallback(
(payload: { cloudAccountId: string; status?: unknown }): void => {
logEvent('Azure Integration: Account connected', {
cloudAccountId: payload.cloudAccountId,
status: payload.status,
});
toast.success('Azure account connected successfully', {
position: 'bottom-right',
});
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.AZURE,
});
handleClose();
},
[handleClose, queryClient],
);
const handleConnectionTimeout = useCallback(
(payload: { id?: string }): void => {
setModalState(ModalStateEnum.ERROR);
logEvent('Azure Integration: Account connection attempt timed out', {
id: payload.id,
});
},
[],
);
const handleConnectionError = useCallback((): void => {
setModalState(ModalStateEnum.ERROR);
}, []);
const { mutate: createAccount } = useCreateAccount();
const handleError = useAxiosError();
const { data: connectionParams, isLoading: isConnectionParamsLoading } =
useGetConnectionCredentials<GetConnectionCredentialsQueryResult>(
{
cloudProvider: INTEGRATION_TYPES.AZURE,
},
{
query: {
onError: handleError,
},
},
);
const handleSubmit = useCallback(async (): Promise<void> => {
try {
setIsLoading(true);
const values = await form.validateFields();
const payload: CloudintegrationtypesPostableAccountDTO = {
config: {
azure: {
deploymentRegion: values.region,
resourceGroups: values.resourceGroups || [],
},
},
credentials: {
ingestionUrl: connectionParams?.data?.ingestionUrl || values.ingestionUrl,
ingestionKey: connectionParams?.data?.ingestionKey || values.ingestionKey,
sigNozApiUrl: connectionParams?.data?.sigNozApiUrl || values.sigNozApiUrl,
sigNozApiKey: connectionParams?.data?.sigNozApiKey || values.sigNozApiKey,
},
};
createAccount(
{
pathParams: { cloudProvider: INTEGRATION_TYPES.AZURE },
data: payload,
},
{
onSuccess: (response: CreateAccountMutationResult) => {
const nextAccountId = response.data.id;
const artifact = response.data.connectionArtifact.azure;
logEvent('Azure Integration: Account connection commands generated', {
id: nextAccountId,
});
setConnectionCommands({
cliCommand: artifact?.cliCommand || '',
cloudPowerShellCommand: artifact?.cloudPowerShellCommand || '',
});
setModalState(ModalStateEnum.WAITING);
setAccountId(nextAccountId);
},
onError: () => {
setModalState(ModalStateEnum.ERROR);
toast.error('Failed to create account connection', {
position: 'bottom-right',
});
},
},
);
} catch (error) {
console.error('Form submission failed:', error);
} finally {
setIsLoading(false);
}
}, [form, connectionParams, createAccount]);
return {
form,
modalState,
isLoading,
accountId,
connectionCommands,
setModalState,
handleSubmit,
handleClose,
connectionParams: connectionParams?.data as
| CloudintegrationtypesCredentialsDTO
| undefined,
isConnectionParamsLoading,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
};
}

View File

@@ -4,7 +4,7 @@
gap: 8px;
padding: 8px;
cursor: pointer;
color: var(--muted-foreground);
color: var(--foreground);
font-family: Inter;
font-size: var(--font-size-sm);
font-weight: 600;
@@ -20,10 +20,13 @@
overflow: hidden;
text-overflow: ellipsis;
&:hover,
&:hover {
background-color: var(--l1-background);
}
&:focus {
outline: none;
background-color: var(--l2-background-hover);
background-color: var(--l1-background);
}
&.disabled {
@@ -44,8 +47,7 @@
}
&:hover {
background-color: var(--danger-background);
color: var(--l1-foreground);
background-color: var(--bg-cherry-100);
}
}
@@ -72,24 +74,73 @@
}
.context-menu-header {
padding: 8px 12px;
border-bottom: 1px solid var(--l2-border);
padding-bottom: 4px;
border-bottom: 1px solid var(--l1-border);
color: var(--muted-foreground);
}
// Target the popover inner specifically for context menu
.context-menu .ant-popover-inner {
padding: 0;
border-radius: 6px;
max-width: 300px;
background: var(--l2-background) !important;
border: 1px solid var(--l2-border) !important;
padding: 12px 8px !important;
// max-height: 254px !important;
max-width: 300px !important;
}
// Dark mode support
.darkMode {
.context-menu-item {
color: var(--muted-foreground);
&:hover,
&:focus {
background-color: var(--l2-background);
}
&.danger {
color: var(--bg-cherry-400);
.icon {
color: var(--bg-cherry-400);
}
&:hover {
background-color: var(--danger-background);
color: var(--l1-foreground);
}
}
.icon {
color: var(--bg-robin-400);
}
}
.context-menu-header {
border-bottom: 1px solid var(--l1-border);
color: var(--muted-foreground);
}
// Set the menu popover background
.context-menu .ant-popover-inner {
background: var(--l1-background) !important;
border: 1px solid var(--border) !important;
}
}
// Context menu backdrop overlay
.context-menu-backdrop {
position: fixed;
inset: 0;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
background: transparent;
cursor: default;
// Prevent any pointer events from reaching elements behind
pointer-events: auto;
// Ensure it covers the entire viewport including any scrollable areas
position: fixed !important;
inset: 0;
}

View File

@@ -7,30 +7,12 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes/dashboardtypesv2"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (provider *provider) addDashboardRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authZ.EditAccess(provider.dashboardHandler.CreateV2), handler.OpenAPIDef{
ID: "CreateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Create dashboard (v2)",
Description: "This endpoint creates a v2-shape dashboard with structured metadata, a typed data tree, and resolved tags.",
Request: new(dashboardtypesv2.PostableDashboard),
RequestContentType: "application/json",
Response: new(dashboardtypesv2.GettableDashboard),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authZ.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
ID: "CreatePublicDashboard",
Tags: []string{"dashboard"},

View File

@@ -25,6 +25,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -51,6 +52,7 @@ type provider struct {
flaggerHandler flagger.Handler
dashboardModule dashboard.Module
dashboardHandler dashboard.Handler
systemDashboardHandler systemdashboard.Handler
metricsExplorerHandler metricsexplorer.Handler
infraMonitoringHandler inframonitoring.Handler
gatewayHandler gateway.Handler
@@ -82,6 +84,7 @@ func NewFactory(
flaggerHandler flagger.Handler,
dashboardModule dashboard.Module,
dashboardHandler dashboard.Handler,
systemDashboardHandler systemdashboard.Handler,
metricsExplorerHandler metricsexplorer.Handler,
infraMonitoringHandler inframonitoring.Handler,
gatewayHandler gateway.Handler,
@@ -116,6 +119,7 @@ func NewFactory(
flaggerHandler,
dashboardModule,
dashboardHandler,
systemDashboardHandler,
metricsExplorerHandler,
infraMonitoringHandler,
gatewayHandler,
@@ -152,6 +156,7 @@ func newProvider(
flaggerHandler flagger.Handler,
dashboardModule dashboard.Module,
dashboardHandler dashboard.Handler,
systemDashboardHandler systemdashboard.Handler,
metricsExplorerHandler metricsexplorer.Handler,
infraMonitoringHandler inframonitoring.Handler,
gatewayHandler gateway.Handler,
@@ -186,6 +191,7 @@ func newProvider(
flaggerHandler: flaggerHandler,
dashboardModule: dashboardModule,
dashboardHandler: dashboardHandler,
systemDashboardHandler: systemDashboardHandler,
metricsExplorerHandler: metricsExplorerHandler,
infraMonitoringHandler: infraMonitoringHandler,
gatewayHandler: gatewayHandler,
@@ -254,6 +260,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addSystemDashboardRoutes(router); err != nil {
return err
}
if err := provider.addMetricsExplorerRoutes(router); err != nil {
return err
}

View File

@@ -0,0 +1,66 @@
package signozapiserver
import (
"net/http"
"github.com/gorilla/mux"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
)
func (provider *provider) addSystemDashboardRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/system/{source}/dashboard", handler.New(provider.authZ.ViewAccess(provider.systemDashboardHandler.Get), handler.OpenAPIDef{
ID: "GetSystemDashboard",
Tags: []string{"system-dashboard"},
Summary: "Get system dashboard",
Description: "This endpoint returns the system dashboard for the callers org keyed by source (e.g. ai-o11y-overview).",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableSystemDashboard),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/system/{source}/dashboard", handler.New(provider.authZ.EditAccess(provider.systemDashboardHandler.Update), handler.OpenAPIDef{
ID: "UpdateSystemDashboard",
Tags: []string{"system-dashboard"},
Summary: "Update system dashboard",
Description: "This endpoint replaces the system dashboard for the callers org with the provided payload.",
Request: new(dashboardtypes.UpdatableSystemDashboard),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableSystemDashboard),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/system/{source}/dashboard/reset", handler.New(provider.authZ.EditAccess(provider.systemDashboardHandler.Reset), handler.OpenAPIDef{
ID: "ResetSystemDashboard",
Tags: []string{"system-dashboard"},
Summary: "Reset system dashboard to defaults",
Description: "This endpoint drops any customisation to the system dashboard, writes the defaults back, and returns the freshly seeded dashboard.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableSystemDashboard),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -22,7 +22,7 @@ func newConfig() factory.Config {
Agent: AgentConfig{
// we will maintain the latest version of cloud integration agent from here,
// till we automate it externally or figure out a way to validate it.
Version: "v0.0.10",
Version: "v0.0.9",
},
}
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes/dashboardtypesv2"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -53,11 +52,6 @@ type Module interface {
statsreporter.StatsCollector
authz.RegisterTypeable
// ════════════════════════════════════════════════════════════════════════
// v2 dashboard methods
// ════════════════════════════════════════════════════════════════════════
CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypesv2.PostableDashboard) (*dashboardtypesv2.Dashboard, error)
}
type Handler interface {
@@ -80,9 +74,4 @@ type Handler interface {
LockUnlock(http.ResponseWriter, *http.Request)
Delete(http.ResponseWriter, *http.Request)
// ════════════════════════════════════════════════════════════════════════
// v2 dashboard methods
// ════════════════════════════════════════════════════════════════════════
CreateV2(http.ResponseWriter, *http.Request)
}

View File

@@ -10,9 +10,7 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
@@ -22,24 +20,20 @@ import (
type module struct {
store dashboardtypes.Store
sqlstore sqlstore.SQLStore
settings factory.ScopedProviderSettings
analytics analytics.Analytics
orgGetter organization.Getter
queryParser queryparser.QueryParser
tagModule tag.Module
}
func NewModule(store dashboardtypes.Store, sqlstore sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, tagModule tag.Module) dashboard.Module {
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard")
return &module{
store: store,
sqlstore: sqlstore,
settings: scopedProviderSettings,
analytics: analytics,
orgGetter: orgGetter,
queryParser: queryParser,
tagModule: tagModule,
}
}

View File

@@ -21,7 +21,7 @@ func NewStore(sqlstore sqlstore.SQLStore) dashboardtypes.Store {
func (store *store) Create(ctx context.Context, storabledashboard *dashboardtypes.StorableDashboard) error {
_, err := store.
sqlstore.
BunDB().
BunDBCtx(ctx).
NewInsert().
Model(storabledashboard).
Exec(ctx)
@@ -55,6 +55,7 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
Model(storableDashboard).
Where("id = ?", id).
Where("org_id = ?", orgID).
Where("source = ?", "").
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "dashboard with id %s doesn't exist", id)
@@ -63,6 +64,23 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
return storableDashboard, nil
}
func (store *store) GetBySource(ctx context.Context, orgID valuer.UUID, source string) (*dashboardtypes.StorableDashboard, error) {
storableDashboard := new(dashboardtypes.StorableDashboard)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storableDashboard).
Where("org_id = ?", orgID).
Where("source = ?", source).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "system dashboard with source %s doesn't exist", source)
}
return storableDashboard, nil
}
func (store *store) GetPublic(ctx context.Context, dashboardID string) (*dashboardtypes.StorablePublicDashboard, error) {
storable := new(dashboardtypes.StorablePublicDashboard)
err := store.
@@ -124,6 +142,7 @@ func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardty
NewSelect().
Model(&storableDashboards).
Where("org_id = ?", orgID).
Where("source = ?", "").
Scan(ctx)
if err != nil {
return nil, err
@@ -158,6 +177,7 @@ func (store *store) Update(ctx context.Context, orgID valuer.UUID, storableDashb
Model(storableDashboard).
WherePK().
Where("org_id = ?", orgID).
Where("source = ?", "").
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "dashboard with id %s doesn't exist", storableDashboard.ID)
@@ -166,6 +186,27 @@ func (store *store) Update(ctx context.Context, orgID valuer.UUID, storableDashb
return nil
}
// UpdateBySource updates the org's system dashboard for the given source.(org_id, source)
// Delete drops and re-creates the row with a new id, last id from a prior Get should not cause this update to match zero rows.
func (store *store) UpdateBySource(ctx context.Context, orgID valuer.UUID, source string, storableDashboard *dashboardtypes.StorableDashboard) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model(storableDashboard).
Set("data = ?", storableDashboard.Data).
Set("updated_by = ?", storableDashboard.UpdatedBy).
Set("updated_at = ?", storableDashboard.UpdatedAt).
Where("org_id = ?", orgID).
Where("source = ?", source).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "system dashboard with source %s doesn't exist", source)
}
return nil
}
func (store *store) UpdatePublic(ctx context.Context, storable *dashboardtypes.StorablePublicDashboard) error {
_, err := store.
sqlstore.
@@ -189,6 +230,7 @@ func (store *store) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUI
Model(new(dashboardtypes.StorableDashboard)).
Where("id = ?", id).
Where("org_id = ?", orgID).
Where("source = ?", "").
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "dashboard with id %s doesn't exist", id)
@@ -197,6 +239,22 @@ func (store *store) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUI
return nil
}
func (store *store) DeleteBySource(ctx context.Context, orgID valuer.UUID, source string) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewDelete().
Model(new(dashboardtypes.StorableDashboard)).
Where("org_id = ?", orgID).
Where("source = ?", source).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "system dashboard with source %s doesn't exist", source)
}
return nil
}
func (store *store) DeletePublic(ctx context.Context, dashboardID string) error {
_, err := store.
sqlstore.

View File

@@ -1,44 +0,0 @@
package impldashboard
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes/dashboardtypesv2"
"github.com/SigNoz/signoz/pkg/valuer"
)
func (handler *handler) CreateV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypesv2.PostableDashboard{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.CreateV2(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.IdentityID()), req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, dashboardtypesv2.NewGettableDashboardFromDashboard(dashboard))
}

View File

@@ -1,48 +0,0 @@
package impldashboard
import (
"context"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes/dashboardtypesv2"
"github.com/SigNoz/signoz/pkg/valuer"
)
func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypesv2.PostableDashboard) (*dashboardtypesv2.Dashboard, error) {
if err := postable.Validate(); err != nil {
return nil, err
}
// Tag upserts run outside the dashboard transaction by design: a successful
// upsert that loses an outer dashboard insert just leaves resolved tag rows
// around for the next attempt — preferable to coupling the two.
resolvedTags, err := module.tagModule.CreateMany(ctx, orgID, postable.Tags, createdBy)
if err != nil {
return nil, err
}
dashboard := dashboardtypesv2.NewDashboard(orgID, createdBy, postable, resolvedTags)
storableDashboard, err := dashboard.ToStorableDashboard()
if err != nil {
return nil, err
}
tagIDs := make([]valuer.UUID, len(resolvedTags))
for i, t := range resolvedTags {
tagIDs[i] = t.ID
}
err = module.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
if err := module.store.Create(ctx, storableDashboard); err != nil {
return err
}
return module.tagModule.LinkToEntity(ctx, orgID, dashboardtypes.EntityTypeDashboard, dashboard.ID, tagIDs)
})
if err != nil {
return nil, err
}
module.analytics.TrackUser(ctx, orgID.String(), creator.String(), "Dashboard Created", dashboardtypes.NewStatsFromStorableDashboards([]*dashboardtypes.StorableDashboard{storableDashboard}))
return dashboard, nil
}

View File

@@ -6,18 +6,20 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
)
type setter struct {
store types.OrganizationStore
alertmanager alertmanager.Alertmanager
quickfilter quickfilter.Module
store types.OrganizationStore
alertmanager alertmanager.Alertmanager
quickfilter quickfilter.Module
systemDashboard systemdashboard.Module
}
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module) organization.Setter {
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter}
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, systemDashboard systemdashboard.Module) organization.Setter {
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, systemDashboard: systemDashboard}
}
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
@@ -33,6 +35,10 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
return err
}
if err := module.systemDashboard.SetDefaultConfig(ctx, organization.ID); err != nil {
return err
}
if err := createManagedRoles(ctx, organization.ID); err != nil {
return err
}

View File

@@ -0,0 +1,138 @@
package implsystemdashboard
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/gorilla/mux"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type handler struct {
module systemdashboard.Module
}
func NewHandler(module systemdashboard.Module) systemdashboard.Handler {
return &handler{module: module}
}
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
source, err := parseSource(r)
if err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.Get(ctx, orgID, source)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboardtypes.NewGettableSystemDashboardFromDashboard(dashboard))
}
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
source, err := parseSource(r)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.UpdatableSystemDashboard{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body"))
return
}
if req.Data == nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "data is required"))
return
}
dashboard, err := handler.module.Update(ctx, orgID, source, claims.Email, req.Data)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboardtypes.NewGettableSystemDashboardFromDashboard(dashboard))
}
func (handler *handler) Reset(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
source, err := parseSource(r)
if err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.Reset(ctx, orgID, source)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboardtypes.NewGettableSystemDashboardFromDashboard(dashboard))
}
func parseSource(r *http.Request) (dashboardtypes.Source, error) {
source := mux.Vars(r)["source"]
if source == "" {
return dashboardtypes.Source{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "source is missing in the path")
}
return dashboardtypes.NewSource(source)
}

View File

@@ -0,0 +1,120 @@
package implsystemdashboard
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct {
store dashboardtypes.Store
}
func NewModule(store dashboardtypes.Store) systemdashboard.Module {
return &module{store: store}
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, source dashboardtypes.Source) (*dashboardtypes.Dashboard, error) {
storableDashboard, err := module.store.GetBySource(ctx, orgID, source.StringValue())
if err != nil {
return nil, err
}
return dashboardtypes.NewDashboardFromStorableDashboard(storableDashboard), nil
}
// Update applies the new payload as last-writer-wins. The Get and Update run inside one transaction so a
// concurrent Reset cannot interleave and leave the response with a stale id from before the reset.
func (module *module) Update(ctx context.Context, orgID valuer.UUID, source dashboardtypes.Source, updatedBy string, data dashboardtypes.UpdatableDashboard) (*dashboardtypes.Dashboard, error) {
var updated *dashboardtypes.Dashboard
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
storableDashboard, err := module.store.GetBySource(ctx, orgID, source.StringValue())
if err != nil {
return err
}
storableDashboard.Data = data
storableDashboard.UpdatedBy = updatedBy
storableDashboard.UpdatedAt = time.Now()
if err := module.store.UpdateBySource(ctx, orgID, source.StringValue(), storableDashboard); err != nil {
return err
}
updated = dashboardtypes.NewDashboardFromStorableDashboard(storableDashboard)
return nil
})
if err != nil {
return nil, err
}
return updated, nil
}
// Reset drops the org's customized system dashboard for the given source and writes the default back in a single transaction. Returns the freshly seeded dashboard so callers can render it without a follow-up Get. A NotFound from the delete is treated as "nothing to drop, proceed with seed" so Reset is self-healing when a row was lost (e.g. hand-deleted in SQL or a prior partial seed).
func (module *module) Reset(ctx context.Context, orgID valuer.UUID, source dashboardtypes.Source) (*dashboardtypes.Dashboard, error) {
var resetDashboard *dashboardtypes.Dashboard
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.DeleteBySource(ctx, orgID, source.StringValue()); err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
defaultDashboard, err := dashboardtypes.NewDefaultSystemDashboard(orgID, source)
if err != nil {
return err
}
storable, err := dashboardtypes.NewStorableDashboardFromDashboard(defaultDashboard)
if err != nil {
return err
}
if err := module.store.Create(ctx, storable); err != nil {
return err
}
resetDashboard = defaultDashboard
return nil
})
if err != nil {
return nil, err
}
return resetDashboard, nil
}
func (module *module) SetDefaultConfig(ctx context.Context, orgID valuer.UUID) error {
for _, source := range dashboardtypes.AllSources {
if err := module.setDefaultForSource(ctx, orgID, source); err != nil {
return err
}
}
return nil
}
func (module *module) setDefaultForSource(ctx context.Context, orgID valuer.UUID, source dashboardtypes.Source) error {
existing, err := module.store.GetBySource(ctx, orgID, source.StringValue())
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
if existing != nil {
return nil
}
dashboard, err := dashboardtypes.NewDefaultSystemDashboard(orgID, source)
if err != nil {
return err
}
storableDashboard, err := dashboardtypes.NewStorableDashboardFromDashboard(dashboard)
if err != nil {
return err
}
return module.store.Create(ctx, storableDashboard)
}

View File

@@ -0,0 +1,25 @@
package systemdashboard
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
Get(ctx context.Context, orgID valuer.UUID, source dashboardtypes.Source) (*dashboardtypes.Dashboard, error)
Update(ctx context.Context, orgID valuer.UUID, source dashboardtypes.Source, updatedBy string, data dashboardtypes.UpdatableDashboard) (*dashboardtypes.Dashboard, error)
Reset(ctx context.Context, orgID valuer.UUID, source dashboardtypes.Source) (*dashboardtypes.Dashboard, error)
SetDefaultConfig(ctx context.Context, orgID valuer.UUID) error
}
// Handler defines the HTTP handler interface for system dashboard endpoints.
// /api/v1/system/{source}/dashboard — Get / Update
// /api/v1/system/{source}/dashboard/reset — Reset.
type Handler interface {
Get(http.ResponseWriter, *http.Request)
Update(http.ResponseWriter, *http.Request)
Reset(http.ResponseWriter, *http.Request)
}

View File

@@ -1,42 +0,0 @@
package impltag
import (
"context"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct {
store tagtypes.Store
}
func NewModule(store tagtypes.Store) tag.Module {
return &module{store: store}
}
func (m *module) CreateMany(ctx context.Context, orgID valuer.UUID, postable []tagtypes.PostableTag, createdBy string) ([]*tagtypes.Tag, error) {
if len(postable) == 0 {
return []*tagtypes.Tag{}, nil
}
toCreate, matched, err := tagtypes.Resolve(ctx, m.store, orgID, postable, createdBy)
if err != nil {
return nil, err
}
created, err := m.store.Create(ctx, toCreate)
if err != nil {
return nil, err
}
return append(matched, created...), nil
}
func (m *module) LinkToEntity(ctx context.Context, orgID valuer.UUID, entityType tagtypes.EntityType, entityID valuer.UUID, tagIDs []valuer.UUID) error {
if len(tagIDs) == 0 {
return nil
}
return m.store.CreateRelations(ctx, tagtypes.NewTagRelations(orgID, entityType, entityID, tagIDs))
}

View File

@@ -1,66 +0,0 @@
package impltag
import (
"context"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type store struct {
sqlstore sqlstore.SQLStore
}
func NewStore(sqlstore sqlstore.SQLStore) tagtypes.Store {
return &store{sqlstore: sqlstore}
}
func (s *store) List(ctx context.Context, orgID valuer.UUID) ([]*tagtypes.Tag, error) {
tags := make([]*tagtypes.Tag, 0)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&tags).
Where("org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, err
}
return tags, nil
}
func (s *store) Create(ctx context.Context, tags []*tagtypes.Tag) ([]*tagtypes.Tag, error) {
if len(tags) == 0 {
return tags, nil
}
// DO UPDATE on a self-set is a deliberate no-op write whose only purpose
// is to make RETURNING fire on conflicting rows. Without it, RETURNING is
// silent on the conflict path and we'd have to refetch by internal name to
// learn the existing rows' IDs after a concurrent-insert race.
err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(&tags).
On("CONFLICT (org_id, internal_name) DO UPDATE").
Set("internal_name = EXCLUDED.internal_name").
Returning("*").
Scan(ctx)
if err != nil {
return nil, err
}
return tags, nil
}
func (s *store) CreateRelations(ctx context.Context, relations []*tagtypes.TagRelation) error {
if len(relations) == 0 {
return nil
}
_, err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(&relations).
On("CONFLICT (entity_id, tag_id) DO NOTHING").
Exec(ctx)
return err
}

View File

@@ -1,140 +0,0 @@
package impltag
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/uptrace/bun"
)
func newTestStore(t *testing.T) sqlstore.SQLStore {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{
MaxOpenConns: 1,
MaxConnLifetime: 0,
},
Sqlite: sqlstore.SqliteConfig{
Path: dbPath,
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
_, err = store.BunDB().NewCreateTable().
Model((*tagtypes.Tag)(nil)).
IfNotExists().
Exec(context.Background())
require.NoError(t, err)
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_tag_org_id_internal_name ON tag (org_id, internal_name)`)
require.NoError(t, err)
return store
}
func tagsByInternalName(t *testing.T, db *bun.DB) map[string]*tagtypes.Tag {
t.Helper()
all := make([]*tagtypes.Tag, 0)
require.NoError(t, db.NewSelect().Model(&all).Scan(context.Background()))
out := map[string]*tagtypes.Tag{}
for _, tag := range all {
out[tag.InternalName] = tag
}
return out
}
func TestStore_Create_PopulatesIDsOnFreshInsert(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
orgID := valuer.GenerateUUID()
tagA := tagtypes.NewTag(orgID, "Database", "database", "u@signoz.io")
tagB := tagtypes.NewTag(orgID, "team/BLR", "team::blr", "u@signoz.io")
preIDA := tagA.ID
preIDB := tagB.ID
got, err := s.Create(ctx, []*tagtypes.Tag{tagA, tagB})
require.NoError(t, err)
require.Len(t, got, 2)
// No race → pre-generated IDs stand. The slice is what we passed in,
// confirming Scan didn't reallocate.
assert.Equal(t, preIDA, got[0].ID)
assert.Equal(t, preIDB, got[1].ID)
// And the rows are in the DB.
stored := tagsByInternalName(t, sqlstore.BunDB())
require.Contains(t, stored, "database")
require.Contains(t, stored, "team::blr")
assert.Equal(t, preIDA, stored["database"].ID)
assert.Equal(t, preIDB, stored["team::blr"].ID)
}
func TestStore_Create_ConflictReturnsExistingRowID(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
orgID := valuer.GenerateUUID()
// Simulate a concurrent insert: someone else has already inserted "database".
winner := tagtypes.NewTag(orgID, "Database", "database", "concurrent")
_, err := s.Create(ctx, []*tagtypes.Tag{winner})
require.NoError(t, err)
winnerID := winner.ID
// Now our request runs with a different pre-generated ID for the same
// internal name. RETURNING should overwrite our stale ID with winner's ID.
loser := tagtypes.NewTag(orgID, "Database", "database", "u@signoz.io")
loserPreID := loser.ID
require.NotEqual(t, winnerID, loserPreID, "pre-generated IDs must differ for this test to be meaningful")
got, err := s.Create(ctx, []*tagtypes.Tag{loser})
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, winnerID, got[0].ID, "returned slice should carry the existing row's ID, not our stale one")
assert.Equal(t, winnerID, loser.ID, "input slice element is mutated in place")
// And the DB still has exactly one row for that internal name — winner's.
stored := tagsByInternalName(t, sqlstore.BunDB())
require.Len(t, stored, 1)
assert.Equal(t, winnerID, stored["database"].ID)
}
func TestStore_Create_MixedFreshAndConflict(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
orgID := valuer.GenerateUUID()
pre := tagtypes.NewTag(orgID, "Database", "database", "concurrent")
_, err := s.Create(ctx, []*tagtypes.Tag{pre})
require.NoError(t, err)
preExistingID := pre.ID
conflict := tagtypes.NewTag(orgID, "Database", "database", "u@signoz.io")
fresh := tagtypes.NewTag(orgID, "team/BLR", "team::blr", "u@signoz.io")
freshPreID := fresh.ID
got, err := s.Create(ctx, []*tagtypes.Tag{conflict, fresh})
require.NoError(t, err)
require.Len(t, got, 2)
assert.Equal(t, preExistingID, got[0].ID, "conflicting row's ID overwritten with the existing row's")
assert.Equal(t, freshPreID, got[1].ID, "fresh row's pre-generated ID is preserved")
}

View File

@@ -1,23 +0,0 @@
package tag
import (
"context"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
// CreateMany resolves user-supplied tag names against the existing tags for the
// org — reusing the casing of any existing parent tag so that
// "teams/blr/platform" inherits the "BLR" casing from a pre-existing
// "teams/BLR" tag — and inserts any tags that don't yet exist.
//
// Does not link the resolved tags to any entity — call LinkToEntity for that.
CreateMany(ctx context.Context, orgID valuer.UUID, postable []tagtypes.PostableTag, createdBy string) ([]*tagtypes.Tag, error)
// LinkToEntity inserts (entity, tag) rows in tag_relations. Existing rows
// are left untouched. Uses the caller's transaction context if any so that
// it can be made atomic with the entity row insert.
LinkToEntity(ctx context.Context, orgID valuer.UUID, entityType tagtypes.EntityType, entityID valuer.UUID, tagIDs []valuer.UUID) error
}

View File

@@ -40,6 +40,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
@@ -55,6 +57,7 @@ type Handlers struct {
SavedView savedview.Handler
Apdex apdex.Handler
Dashboard dashboard.Handler
SystemDashboard systemdashboard.Handler
QuickFilter quickfilter.Handler
TraceFunnel tracefunnel.Handler
RawDataExport rawdataexport.Handler
@@ -99,6 +102,7 @@ func NewHandlers(
SavedView: implsavedview.NewHandler(modules.SavedView),
Apdex: implapdex.NewHandler(modules.Apdex),
Dashboard: impldashboard.NewHandler(modules.Dashboard, providerSettings, authz),
SystemDashboard: implsystemdashboard.NewHandler(modules.SystemDashboard),
QuickFilter: implquickfilter.NewHandler(modules.QuickFilter),
TraceFunnel: impltracefunnel.NewHandler(modules.TraceFunnel),
RawDataExport: implrawdataexport.NewHandler(modules.RawDataExport),

View File

@@ -16,7 +16,6 @@ import (
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
@@ -45,8 +44,7 @@ func TestNewHandlers(t *testing.T) {
emailing := emailingtest.New()
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), sqlstore, providerSettings, nil, orgGetter, queryParser, tagModule)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)
@@ -54,7 +52,7 @@ func TestNewHandlers(t *testing.T) {
userRoleStore := impluser.NewUserRoleStore(sqlstore, providerSettings)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, flagger, tagModule)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, flagger)
querierHandler := querier.NewHandler(providerSettings, nil, nil)
registryHandler := factory.NewHandler(nil)

View File

@@ -17,6 +17,7 @@ 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/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/organization"
@@ -40,7 +41,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
@@ -67,6 +69,7 @@ type Modules struct {
SavedView savedview.Module
Apdex apdex.Module
Dashboard dashboard.Module
SystemDashboard systemdashboard.Module
QuickFilter quickfilter.Module
TraceFunnel tracefunnel.Module
RawDataExport rawdataexport.Module
@@ -81,7 +84,6 @@ type Modules struct {
CloudIntegration cloudintegration.Module
RuleStateHistory rulestatehistory.Module
TraceDetail tracedetail.Module
Tag tag.Module
}
func NewModules(
@@ -106,10 +108,10 @@ func NewModules(
serviceAccount serviceaccount.Module,
cloudIntegrationModule cloudintegration.Module,
fl flagger.Flagger,
tagModule tag.Module,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
systemDashboard := implsystemdashboard.NewModule(impldashboard.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, systemDashboard)
userSetter := impluser.NewSetter(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User, userRoleStore, userGetter)
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
@@ -120,6 +122,7 @@ func NewModules(
SavedView: implsavedview.NewModule(sqlstore),
Apdex: implapdex.NewModule(sqlstore),
Dashboard: dashboard,
SystemDashboard: systemDashboard,
UserSetter: userSetter,
UserGetter: userGetter,
QuickFilter: quickfilter,
@@ -136,6 +139,5 @@ func NewModules(
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
Tag: tagModule,
}
}

View File

@@ -18,7 +18,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sharder"
@@ -46,8 +45,7 @@ func TestNewModules(t *testing.T) {
emailing := emailingtest.New()
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), sqlstore, providerSettings, nil, orgGetter, queryParser, tagModule)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)
@@ -58,7 +56,7 @@ func TestNewModules(t *testing.T) {
serviceAccount := implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), nil, nil, nil, providerSettings, serviceaccount.Config{})
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), flagger, tagModule)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), flagger)
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -30,6 +30,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -63,6 +64,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ flagger.Handler }{},
struct{ dashboard.Module }{},
struct{ dashboard.Handler }{},
struct{ systemdashboard.Handler }{},
struct{ metricsexplorer.Handler }{},
struct{ inframonitoring.Handler }{},
struct{ gateway.Handler }{},

View File

@@ -195,7 +195,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewServiceAccountAuthzactory(sqlstore),
sqlmigration.NewDropUserDeletedAtFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateAWSAllRegionsFactory(sqlstore),
sqlmigration.NewAddTagsFactory(sqlstore, sqlschema),
sqlmigration.NewAddSystemDashboardFactory(sqlstore, sqlschema),
)
}
@@ -270,6 +270,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.FlaggerHandler,
modules.Dashboard,
handlers.Dashboard,
handlers.SystemDashboard,
handlers.MetricsExplorer,
handlers.InfraMonitoring,
handlers.GatewayHandler,

View File

@@ -29,8 +29,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
@@ -103,7 +101,7 @@ func New(
telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]],
authNsCallback func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error),
authzCallback func(context.Context, sqlstore.SQLStore, licensing.Licensing, []authz.OnBeforeRoleDelete, dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing, tag.Module) dashboard.Module,
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing) dashboard.Module,
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
querierHandlerCallback func(factory.ProviderSettings, querier.Querier, analytics.Analytics) querier.Handler,
@@ -327,13 +325,8 @@ func New(
// Initialize query parser (needed for dashboard module)
queryParser := queryparser.New(providerSettings)
// Initialize tag module — shared across modules that link entities to tags
// (currently dashboard; future: alerts, RBAC). Built once here and injected
// where needed.
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
// Initialize dashboard module (needed for authz registry)
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing)
// Initialize user getter
userGetter := impluser.NewGetter(userStore, userRoleStore, flagger)
@@ -448,7 +441,7 @@ func New(
}
// Initialize all modules
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule, flagger, tagModule)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule, flagger)
// Initialize ruler from the variant-specific provider factories
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")

View File

@@ -0,0 +1,120 @@
package sqlmigration
import (
"context"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type addSystemDashboard struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddSystemDashboardFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_system_dashboard"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addSystemDashboard{sqlstore: sqlstore, sqlschema: sqlschema}, nil
})
}
func (migration *addSystemDashboard) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *addSystemDashboard) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
exists, err := migration.sqlstore.Dialect().ColumnExists(ctx, tx, "dashboard", "source")
if err != nil {
return err
}
if !exists {
table, uniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("dashboard"))
if err != nil {
return err
}
column := &sqlschema.Column{
Name: sqlschema.ColumnName("source"),
DataType: sqlschema.DataTypeText,
Nullable: false,
}
sqls := migration.sqlschema.Operator().AddColumn(table, uniqueConstraints, column, "")
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
}
var orgIDs []string
if err := tx.NewSelect().Model((*types.Organization)(nil)).Column("id").Scan(ctx, &orgIDs); err != nil {
return err
}
for _, rawOrgID := range orgIDs {
orgID, err := valuer.NewUUID(rawOrgID)
if err != nil {
return err
}
for _, source := range dashboardtypes.AllSources {
count, err := tx.NewSelect().
Model((*dashboardtypes.StorableDashboard)(nil)).
Where("org_id = ?", orgID).
Where("source = ?", source.StringValue()).
Count(ctx)
if err != nil {
return err
}
if count > 0 {
continue
}
dashboard, err := dashboardtypes.NewDefaultSystemDashboard(orgID, source)
if err != nil {
return err
}
storable, err := dashboardtypes.NewStorableDashboardFromDashboard(dashboard)
if err != nil {
return err
}
if _, err := tx.NewInsert().Model(storable).Exec(ctx); err != nil {
return err
}
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (migration *addSystemDashboard) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -1,103 +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 addTags struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddTagsFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_tags"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addTags{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addTags) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addTags) 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{}
tagTableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "tag",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "internal_name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "created_by", DataType: sqlschema.DataTypeText, Nullable: true},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_by", DataType: sqlschema.DataTypeText, Nullable: 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, tagTableSQLs...)
tagUniqueIndexSQL := migration.sqlschema.Operator().CreateIndex(
&sqlschema.UniqueIndex{
TableName: "tag",
ColumnNames: []sqlschema.ColumnName{"org_id", "internal_name"},
})
sqls = append(sqls, tagUniqueIndexSQL...)
tagRelationsTableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "tag_relations",
Columns: []*sqlschema.Column{
{Name: "entity_type", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "entity_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "tag_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"entity_id", "tag_id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("org_id"),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
sqls = append(sqls, tagRelationsTableSQLs...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addTags) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,107 @@
{
"title": "AI Observability Overview",
"description": "AI / LLM observability overview — cost, tokens, latency, errors, RED for tool calls, and time to first token. Scoped by model, environment and service (apply via the variable bar).",
"tags": ["ai", "llm", "genai", "overview"],
"version": "v5",
"variables": {
"model": {
"id": "a1000000-0000-0000-0000-000000000001",
"name": "model",
"key": "model",
"description": "LLM model",
"type": "QUERY",
"sort": "ASC",
"multiSelect": true,
"showALLOption": true,
"allSelected": true,
"queryValue": "SELECT DISTINCT attributes_string['gen_ai.request.model'] AS model FROM signoz_traces.distributed_signoz_index_v3 WHERE mapContains(attributes_string, 'gen_ai.request.model') AND timestamp >= now() - INTERVAL 1 DAY",
"customValue": "",
"textboxValue": "",
"selectedValue": [],
"order": 0,
"modificationUUID": "a1000000-0000-0000-0000-000000000011"
},
"environment": {
"id": "a1000000-0000-0000-0000-000000000002",
"name": "environment",
"key": "environment",
"description": "Deployment environment",
"type": "QUERY",
"sort": "ASC",
"multiSelect": true,
"showALLOption": true,
"allSelected": true,
"queryValue": "SELECT DISTINCT resources_string['deployment.environment'] AS environment FROM signoz_traces.distributed_signoz_index_v3 WHERE mapContains(resources_string, 'deployment.environment') AND timestamp >= now() - INTERVAL 1 DAY",
"customValue": "",
"textboxValue": "",
"selectedValue": [],
"order": 1,
"modificationUUID": "a1000000-0000-0000-0000-000000000012"
},
"service_name": {
"id": "a1000000-0000-0000-0000-000000000003",
"name": "service_name",
"key": "service_name",
"description": "Service name",
"type": "QUERY",
"sort": "ASC",
"multiSelect": true,
"showALLOption": true,
"allSelected": true,
"queryValue": "SELECT DISTINCT resources_string['service.name'] AS service_name FROM signoz_traces.distributed_signoz_index_v3 WHERE mapContains(resources_string, 'service.name') AND timestamp >= now() - INTERVAL 1 DAY",
"customValue": "",
"textboxValue": "",
"selectedValue": [],
"order": 2,
"modificationUUID": "a1000000-0000-0000-0000-000000000013"
}
},
"layout": [
{"i": "11111111-1111-1111-1111-111111111111", "x": 0, "y": 0, "w": 3, "h": 3, "moved": false, "static": false},
{"i": "22222222-2222-2222-2222-222222222222", "x": 3, "y": 0, "w": 3, "h": 3, "moved": false, "static": false},
{"i": "33333333-3333-3333-3333-333333333333", "x": 6, "y": 0, "w": 2, "h": 3, "moved": false, "static": false},
{"i": "44444444-4444-4444-4444-444444444444", "x": 8, "y": 0, "w": 2, "h": 3, "moved": false, "static": false},
{"i": "55555555-5555-5555-5555-555555555555", "x": 10, "y": 0, "w": 2, "h": 3, "moved": false, "static": false},
{"i": "66666666-6666-6666-6666-666666666666", "x": 0, "y": 3, "w": 6, "h": 4, "moved": false, "static": false},
{"i": "77777777-7777-7777-7777-777777777777", "x": 6, "y": 3, "w": 6, "h": 4, "moved": false, "static": false},
{"i": "88888888-8888-8888-8888-888888888888", "x": 0, "y": 7, "w": 6, "h": 4, "moved": false, "static": false},
{"i": "99999999-9999-9999-9999-999999999999", "x": 6, "y": 7, "w": 6, "h": 4, "moved": false, "static": false},
{"i": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "x": 0, "y": 11, "w": 4, "h": 4, "moved": false, "static": false},
{"i": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "x": 4, "y": 11, "w": 4, "h": 4, "moved": false, "static": false},
{"i": "cccccccc-cccc-cccc-cccc-cccccccccccc", "x": 8, "y": 11, "w": 4, "h": 4, "moved": false, "static": false},
{"i": "dddddddd-dddd-dddd-dddd-dddddddddddd", "x": 0, "y": 15, "w": 4, "h": 4, "moved": false, "static": false},
{"i": "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "x": 4, "y": 15, "w": 4, "h": 4, "moved": false, "static": false},
{"i": "ffffffff-ffff-ffff-ffff-ffffffffffff", "x": 8, "y": 15, "w": 4, "h": 4, "moved": false, "static": false}
],
"widgets": [
{"id": "11111111-1111-1111-1111-111111111111", "title": "Total cost", "description": "Total LLM cost across all calls. Requires gen_ai.usage.cost attribute.", "panelTypes": "value", "nullZeroValues": "zero", "opacity": "1", "isStacked": false, "fillSpans": false, "yAxisUnit": "none", "timePreferance": "GLOBAL_TIME", "softMax": null, "softMin": null, "thresholds": [], "selectedLogFields": [], "selectedTracesFields": [], "query": {"queryType": "builder", "promql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "clickhouse_sql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "id": "q1111111-1111-1111-1111-111111111111", "builder": {"queryData": [{"queryName": "A", "stepInterval": 60, "dataSource": "traces", "aggregations": [{"expression": "sum(gen_ai.usage.cost)"}], "filter": {"expression": ""}, "groupBy": [], "expression": "A", "orderBy": [], "legend": "", "disabled": false, "having": {"expression": ""}, "limit": null, "reduceTo": "sum"}], "queryFormulas": [], "queryTraceOperator": []}}},
{"id": "22222222-2222-2222-2222-222222222222", "title": "Total tokens", "description": "Sum of input + output tokens.", "panelTypes": "value", "nullZeroValues": "zero", "opacity": "1", "isStacked": false, "fillSpans": false, "yAxisUnit": "short", "timePreferance": "GLOBAL_TIME", "softMax": null, "softMin": null, "thresholds": [], "selectedLogFields": [], "selectedTracesFields": [], "query": {"queryType": "builder", "promql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "clickhouse_sql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "id": "q2222222-2222-2222-2222-222222222222", "builder": {"queryData": [{"queryName": "A", "stepInterval": 60, "dataSource": "traces", "aggregations": [{"expression": "sum(gen_ai.usage.input_tokens) + sum(gen_ai.usage.output_tokens)"}], "filter": {"expression": ""}, "groupBy": [], "expression": "A", "orderBy": [], "legend": "", "disabled": false, "having": {"expression": ""}, "limit": null, "reduceTo": "sum"}], "queryFormulas": [], "queryTraceOperator": []}}},
{"id": "33333333-3333-3333-3333-333333333333", "title": "Avg latency (p95)", "description": "p95 latency of LLM spans.", "panelTypes": "value", "nullZeroValues": "zero", "opacity": "1", "isStacked": false, "fillSpans": false, "yAxisUnit": "ns", "timePreferance": "GLOBAL_TIME", "softMax": null, "softMin": null, "thresholds": [], "selectedLogFields": [], "selectedTracesFields": [], "query": {"queryType": "builder", "promql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "clickhouse_sql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "id": "q3333333-3333-3333-3333-333333333333", "builder": {"queryData": [{"queryName": "A", "stepInterval": 60, "dataSource": "traces", "aggregations": [{"expression": "p95(duration_nano)"}], "filter": {"expression": "gen_ai.system != ''"}, "groupBy": [], "expression": "A", "orderBy": [], "legend": "", "disabled": false, "having": {"expression": ""}, "limit": null, "reduceTo": "avg"}], "queryFormulas": [], "queryTraceOperator": []}}},
{"id": "44444444-4444-4444-4444-444444444444", "title": "Error rate", "description": "Error rate as a percentage of total LLM calls.", "panelTypes": "value", "nullZeroValues": "zero", "opacity": "1", "isStacked": false, "fillSpans": false, "yAxisUnit": "percent", "timePreferance": "GLOBAL_TIME", "softMax": null, "softMin": null, "thresholds": [], "selectedLogFields": [], "selectedTracesFields": [], "query": {"queryType": "builder", "promql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "clickhouse_sql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "id": "q4444444-4444-4444-4444-444444444444", "builder": {"queryData": [{"queryName": "A", "stepInterval": 60, "dataSource": "traces", "aggregations": [{"expression": "countIf(has_error = true) * 100 / count()"}], "filter": {"expression": "gen_ai.system != ''"}, "groupBy": [], "expression": "A", "orderBy": [], "legend": "", "disabled": false, "having": {"expression": ""}, "limit": null, "reduceTo": "avg"}], "queryFormulas": [], "queryTraceOperator": []}}},
{"id": "55555555-5555-5555-5555-555555555555", "title": "TTFT (p95)", "description": "p95 time to first token.", "panelTypes": "value", "nullZeroValues": "zero", "opacity": "1", "isStacked": false, "fillSpans": false, "yAxisUnit": "ms", "timePreferance": "GLOBAL_TIME", "softMax": null, "softMin": null, "thresholds": [], "selectedLogFields": [], "selectedTracesFields": [], "query": {"queryType": "builder", "promql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "clickhouse_sql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "id": "q5555555-5555-5555-5555-555555555555", "builder": {"queryData": [{"queryName": "A", "stepInterval": 60, "dataSource": "traces", "aggregations": [{"expression": "p95(gen_ai.server.ttft)"}], "filter": {"expression": ""}, "groupBy": [], "expression": "A", "orderBy": [], "legend": "", "disabled": false, "having": {"expression": ""}, "limit": null, "reduceTo": "avg"}], "queryFormulas": [], "queryTraceOperator": []}}},
{"id": "66666666-6666-6666-6666-666666666666", "title": "Cost over time", "description": "Cost by model over time.", "panelTypes": "graph", "nullZeroValues": "zero", "opacity": "1", "isStacked": false, "fillSpans": false, "yAxisUnit": "none", "timePreferance": "GLOBAL_TIME", "softMax": null, "softMin": null, "thresholds": [], "selectedLogFields": [], "selectedTracesFields": [], "query": {"queryType": "builder", "promql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "clickhouse_sql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "id": "q6666666-6666-6666-6666-666666666666", "builder": {"queryData": [{"queryName": "A", "stepInterval": 60, "dataSource": "traces", "aggregations": [{"expression": "sum(gen_ai.usage.cost)"}], "filter": {"expression": ""}, "groupBy": [{"key": "gen_ai.request.model", "dataType": "string", "type": "tag", "isColumn": false, "isJSON": false}], "expression": "A", "orderBy": [], "legend": "{{gen_ai.request.model}}", "disabled": false, "having": {"expression": ""}, "limit": null}], "queryFormulas": [], "queryTraceOperator": []}}},
{"id": "77777777-7777-7777-7777-777777777777", "title": "Token usage over time", "description": "Input vs output tokens over time.", "panelTypes": "graph", "nullZeroValues": "zero", "opacity": "1", "isStacked": true, "fillSpans": false, "yAxisUnit": "short", "timePreferance": "GLOBAL_TIME", "softMax": null, "softMin": null, "thresholds": [], "selectedLogFields": [], "selectedTracesFields": [], "query": {"queryType": "builder", "promql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "clickhouse_sql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "id": "q7777777-7777-7777-7777-777777777777", "builder": {"queryData": [{"queryName": "A", "stepInterval": 60, "dataSource": "traces", "aggregations": [{"expression": "sum(gen_ai.usage.input_tokens)"}], "filter": {"expression": ""}, "groupBy": [], "expression": "A", "orderBy": [], "legend": "Input", "disabled": false, "having": {"expression": ""}, "limit": null}, {"queryName": "B", "stepInterval": 60, "dataSource": "traces", "aggregations": [{"expression": "sum(gen_ai.usage.output_tokens)"}], "filter": {"expression": ""}, "groupBy": [], "expression": "B", "orderBy": [], "legend": "Output", "disabled": false, "having": {"expression": ""}, "limit": null}], "queryFormulas": [], "queryTraceOperator": []}}},
{"id": "88888888-8888-8888-8888-888888888888", "title": "LLM call latency percentiles", "description": "p50, p90, p95, p99 latency by model.", "panelTypes": "table", "nullZeroValues": "zero", "opacity": "1", "isStacked": false, "fillSpans": false, "yAxisUnit": "ns", "timePreferance": "GLOBAL_TIME", "softMax": null, "softMin": null, "thresholds": [], "selectedLogFields": [], "selectedTracesFields": [], "query": {"queryType": "builder", "promql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "clickhouse_sql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "id": "q8888888-8888-8888-8888-888888888888", "builder": {"queryData": [{"queryName": "A", "stepInterval": 60, "dataSource": "traces", "aggregations": [{"expression": "p50(duration_nano)"}, {"expression": "p90(duration_nano)"}, {"expression": "p95(duration_nano)"}, {"expression": "p99(duration_nano)"}], "filter": {"expression": "gen_ai.system != ''"}, "groupBy": [{"key": "gen_ai.request.model", "dataType": "string", "type": "tag", "isColumn": false, "isJSON": false}], "expression": "A", "orderBy": [], "legend": "{{gen_ai.request.model}}", "disabled": false, "having": {"expression": ""}, "limit": null}], "queryFormulas": [], "queryTraceOperator": []}}},
{"id": "99999999-9999-9999-9999-999999999999", "title": "LLM call latency over time", "description": "p95 latency trend by model.", "panelTypes": "graph", "nullZeroValues": "zero", "opacity": "1", "isStacked": false, "fillSpans": false, "yAxisUnit": "ns", "timePreferance": "GLOBAL_TIME", "softMax": null, "softMin": null, "thresholds": [], "selectedLogFields": [], "selectedTracesFields": [], "query": {"queryType": "builder", "promql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "clickhouse_sql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "id": "q9999999-9999-9999-9999-999999999999", "builder": {"queryData": [{"queryName": "A", "stepInterval": 60, "dataSource": "traces", "aggregations": [{"expression": "p95(duration_nano)"}], "filter": {"expression": "gen_ai.system != ''"}, "groupBy": [{"key": "gen_ai.request.model", "dataType": "string", "type": "tag", "isColumn": false, "isJSON": false}], "expression": "A", "orderBy": [], "legend": "{{gen_ai.request.model}}", "disabled": false, "having": {"expression": ""}, "limit": null}], "queryFormulas": [], "queryTraceOperator": []}}},
{"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "title": "Error count", "description": "Errors grouped by error type.", "panelTypes": "graph", "nullZeroValues": "zero", "opacity": "1", "isStacked": true, "fillSpans": false, "yAxisUnit": "short", "timePreferance": "GLOBAL_TIME", "softMax": null, "softMin": null, "thresholds": [], "selectedLogFields": [], "selectedTracesFields": [], "query": {"queryType": "builder", "promql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "clickhouse_sql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "id": "qaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "builder": {"queryData": [{"queryName": "A", "stepInterval": 60, "dataSource": "traces", "aggregations": [{"expression": "count()"}], "filter": {"expression": "has_error = true AND gen_ai.system != ''"}, "groupBy": [{"key": "gen_ai.error.type", "dataType": "string", "type": "tag", "isColumn": false, "isJSON": false}], "expression": "A", "orderBy": [], "legend": "{{gen_ai.error.type}}", "disabled": false, "having": {"expression": ""}, "limit": null}], "queryFormulas": [], "queryTraceOperator": []}}},
{"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "title": "Time to first token", "description": "p95 TTFT by model.", "panelTypes": "graph", "nullZeroValues": "zero", "opacity": "1", "isStacked": false, "fillSpans": false, "yAxisUnit": "ms", "timePreferance": "GLOBAL_TIME", "softMax": null, "softMin": null, "thresholds": [], "selectedLogFields": [], "selectedTracesFields": [], "query": {"queryType": "builder", "promql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "clickhouse_sql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "id": "qbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "builder": {"queryData": [{"queryName": "A", "stepInterval": 60, "dataSource": "traces", "aggregations": [{"expression": "p95(gen_ai.server.ttft)"}], "filter": {"expression": ""}, "groupBy": [{"key": "gen_ai.request.model", "dataType": "string", "type": "tag", "isColumn": false, "isJSON": false}], "expression": "A", "orderBy": [], "legend": "{{gen_ai.request.model}}", "disabled": false, "having": {"expression": ""}, "limit": null}], "queryFormulas": [], "queryTraceOperator": []}}},
{"id": "cccccccc-cccc-cccc-cccc-cccccccccccc", "title": "Top 10 span names", "description": "Top span names by count across GenAI spans.", "panelTypes": "table", "nullZeroValues": "zero", "opacity": "1", "isStacked": false, "fillSpans": false, "yAxisUnit": "none", "timePreferance": "GLOBAL_TIME", "softMax": null, "softMin": null, "thresholds": [], "selectedLogFields": [], "selectedTracesFields": [], "query": {"queryType": "builder", "promql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "clickhouse_sql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "id": "qccccccc-cccc-cccc-cccc-cccccccccccc", "builder": {"queryData": [{"queryName": "A", "stepInterval": 60, "dataSource": "traces", "aggregations": [{"expression": "count()"}], "filter": {"expression": "gen_ai.system != ''"}, "groupBy": [{"key": "name", "dataType": "string", "type": "tag", "isColumn": true, "isJSON": false}], "expression": "A", "orderBy": [{"columnName": "count()", "order": "desc"}], "legend": "{{name}}", "disabled": false, "having": {"expression": ""}, "limit": 10}], "queryFormulas": [], "queryTraceOperator": []}}},
{"id": "dddddddd-dddd-dddd-dddd-dddddddddddd", "title": "Tool call rate", "description": "Tool call rate per second.", "panelTypes": "graph", "nullZeroValues": "zero", "opacity": "1", "isStacked": false, "fillSpans": false, "yAxisUnit": "reqps", "timePreferance": "GLOBAL_TIME", "softMax": null, "softMin": null, "thresholds": [], "selectedLogFields": [], "selectedTracesFields": [], "query": {"queryType": "builder", "promql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "clickhouse_sql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "id": "qddddddd-dddd-dddd-dddd-dddddddddddd", "builder": {"queryData": [{"queryName": "A", "stepInterval": 60, "dataSource": "traces", "aggregations": [{"expression": "rate()"}], "filter": {"expression": "name = 'execute_tool'"}, "groupBy": [], "expression": "A", "orderBy": [], "legend": "req/s", "disabled": false, "having": {"expression": ""}, "limit": null}], "queryFormulas": [], "queryTraceOperator": []}}},
{"id": "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "title": "Tool error rate", "description": "Percentage of tool calls that errored.", "panelTypes": "graph", "nullZeroValues": "zero", "opacity": "1", "isStacked": false, "fillSpans": false, "yAxisUnit": "percent", "timePreferance": "GLOBAL_TIME", "softMax": null, "softMin": null, "thresholds": [], "selectedLogFields": [], "selectedTracesFields": [], "query": {"queryType": "builder", "promql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "clickhouse_sql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "id": "qeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "builder": {"queryData": [{"queryName": "A", "stepInterval": 60, "dataSource": "traces", "aggregations": [{"expression": "countIf(has_error = true) * 100 / count()"}], "filter": {"expression": "name = 'execute_tool'"}, "groupBy": [], "expression": "A", "orderBy": [], "legend": "error %", "disabled": false, "having": {"expression": ""}, "limit": null}], "queryFormulas": [], "queryTraceOperator": []}}},
{"id": "ffffffff-ffff-ffff-ffff-ffffffffffff", "title": "Tool duration (p50)", "description": "Median tool call duration.", "panelTypes": "graph", "nullZeroValues": "zero", "opacity": "1", "isStacked": false, "fillSpans": false, "yAxisUnit": "ns", "timePreferance": "GLOBAL_TIME", "softMax": null, "softMin": null, "thresholds": [], "selectedLogFields": [], "selectedTracesFields": [], "query": {"queryType": "builder", "promql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "clickhouse_sql": [{"disabled": false, "legend": "", "name": "A", "query": ""}], "id": "qfffffff-ffff-ffff-ffff-ffffffffffff", "builder": {"queryData": [{"queryName": "A", "stepInterval": 60, "dataSource": "traces", "aggregations": [{"expression": "p50(duration_nano)"}], "filter": {"expression": "name = 'execute_tool'"}, "groupBy": [], "expression": "A", "orderBy": [], "legend": "p50", "disabled": false, "having": {"expression": ""}, "limit": null}], "queryFormulas": [], "queryTraceOperator": []}}}
]
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
@@ -20,8 +19,6 @@ var (
TypeableMetaResourceDashboard = authtypes.MustNewTypeableMetaResource(authtypes.MustNewName("dashboard"))
TypeableMetaResourcePublicDashboard = authtypes.MustNewTypeableMetaResource(authtypes.MustNewName("public-dashboard"))
TypeableMetaResourcesDashboards = authtypes.MustNewTypeableMetaResources(authtypes.MustNewName("dashboards"))
EntityTypeDashboard = tagtypes.MustNewEntityType("dashboard")
)
var (
@@ -40,6 +37,7 @@ type StorableDashboard struct {
Data StorableDashboardData `bun:"data,type:text,notnull"`
Locked bool `bun:"locked,notnull,default:false"`
OrgID valuer.UUID `bun:"org_id,notnull"`
Source string `bun:"source,type:text,notnull"`
}
type Dashboard struct {
@@ -50,6 +48,7 @@ type Dashboard struct {
Data StorableDashboardData `json:"data"`
Locked bool `json:"locked"`
OrgID valuer.UUID `json:"org_id"`
Source string `json:"source"`
}
type LockUnlockDashboard struct {
@@ -89,6 +88,7 @@ func NewStorableDashboardFromDashboard(dashboard *Dashboard) (*StorableDashboard
OrgID: dashboard.OrgID,
Data: dashboard.Data,
Locked: dashboard.Locked,
Source: dashboard.Source,
}, nil
}
@@ -111,6 +111,30 @@ func NewDashboard(orgID valuer.UUID, createdBy string, storableDashboardData Sto
}, nil
}
// NewSystemDashboard builds a Dashboard owned by the system identified by source (e.g. "ai-o11y-overview").
func NewSystemDashboard(orgID valuer.UUID, source string, data StorableDashboardData) (*Dashboard, error) {
if source == "" {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "source is required for a system dashboard")
}
currentTime := time.Now()
return &Dashboard{
ID: valuer.GenerateUUID().StringValue(),
TimeAuditable: types.TimeAuditable{
CreatedAt: currentTime,
UpdatedAt: currentTime,
},
UserAuditable: types.UserAuditable{
CreatedBy: "system",
UpdatedBy: "system",
},
OrgID: orgID,
Data: data,
Source: source,
}, nil
}
func NewDashboardFromStorableDashboard(storableDashboard *StorableDashboard) *Dashboard {
return &Dashboard{
ID: storableDashboard.ID.StringValue(),
@@ -125,6 +149,7 @@ func NewDashboardFromStorableDashboard(storableDashboard *StorableDashboard) *Da
OrgID: storableDashboard.OrgID,
Data: storableDashboard.Data,
Locked: storableDashboard.Locked,
Source: storableDashboard.Source,
}
}
@@ -157,6 +182,7 @@ func NewGettableDashboardFromDashboard(dashboard *Dashboard) (*GettableDashboard
OrgID: dashboard.OrgID,
Data: dashboard.Data,
Locked: dashboard.Locked,
Source: dashboard.Source,
}, nil
}

View File

@@ -0,0 +1,262 @@
package dashboardtypes
import (
"bytes"
"encoding/json"
"fmt"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/go-playground/validator/v10"
v1 "github.com/perses/perses/pkg/model/api/v1"
"github.com/perses/perses/pkg/model/api/v1/common"
"github.com/perses/perses/pkg/model/api/v1/dashboard"
)
// StorableDashboardDataV2 wraps v1.DashboardSpec (Perses) with additional SigNoz-specific fields.
//
// We embed DashboardSpec (not v1.Dashboard) to avoid carrying Perses's Metadata
// (Name, Project, CreatedAt, UpdatedAt, Tags, Version) and Kind field. SigNoz
// manages identity (ID), timestamps (TimeAuditable), and multi-tenancy (OrgID)
// separately on StorableDashboardV2/DashboardV2.
//
// The following v1 request fields map to locations inside v1.DashboardSpec:
// - title → Display.Name (common.Display)
// - description → Display.Description (common.Display)
//
// Fields that have no Perses equivalent will be added in this wrapper (like image, uploadGrafana, etc.)
type StorableDashboardDataV2 = v1.DashboardSpec
// UnmarshalAndValidateDashboardV2JSON unmarshals the JSON into a StorableDashboardDataV2
// (= PostableDashboardV2 = UpdatableDashboardV2) and validates plugin kinds and specs.
func UnmarshalAndValidateDashboardV2JSON(data []byte) (*StorableDashboardDataV2, error) {
var d StorableDashboardDataV2
// Note: DashboardSpec has a custom UnmarshalJSON which prevents
// DisallowUnknownFields from working at the top level. Unknown
// fields in plugin specs are still rejected by validateAndNormalizePluginSpec.
if err := json.Unmarshal(data, &d); err != nil {
return nil, err
}
if err := validateDashboardV2(d); err != nil {
return nil, err
}
return &d, nil
}
// Plugin kind → spec type factory. Each value is a pointer to the zero value of the
// expected spec struct. validatePluginSpec marshals plugin.Spec back to JSON and
// unmarshals into the typed struct to catch field-level errors.
var (
panelPluginSpecs = map[PanelPluginKind]func() any{
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
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) },
}
queryPluginSpecs = map[QueryPluginKind]func() any{
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
QueryKindComposite: func() any { return new(CompositeQuerySpec) },
QueryKindFormula: func() any { return new(FormulaSpec) },
QueryKindPromQL: func() any { return new(PromQLQuerySpec) },
QueryKindClickHouseSQL: func() any { return new(ClickHouseSQLQuerySpec) },
QueryKindTraceOperator: func() any { return new(TraceOperatorSpec) },
}
variablePluginSpecs = map[VariablePluginKind]func() any{
VariableKindDynamic: func() any { return new(DynamicVariableSpec) },
VariableKindQuery: func() any { return new(QueryVariableSpec) },
VariableKindCustom: func() any { return new(CustomVariableSpec) },
VariableKindTextbox: func() any { return new(TextboxVariableSpec) },
}
datasourcePluginSpecs = map[DatasourcePluginKind]func() any{
DatasourceKindSigNoz: func() any { return new(struct{}) },
}
// allowedQueryKinds maps each panel plugin kind to the query plugin
// kinds it supports. Composite sub-query types are mapped to these
// same kind strings via compositeSubQueryTypeToPluginKind.
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder},
}
// compositeSubQueryTypeToPluginKind maps CompositeQuery sub-query type
// strings to the equivalent top-level query plugin kind for validation.
compositeSubQueryTypeToPluginKind = map[qb.QueryType]QueryPluginKind{
qb.QueryTypeBuilder: QueryKindBuilder,
qb.QueryTypeFormula: QueryKindFormula,
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
qb.QueryTypePromQL: QueryKindPromQL,
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
}
)
func validateDashboardV2(d StorableDashboardDataV2) error {
for name, ds := range d.Datasources {
if err := validateDatasourcePlugin(&ds.Plugin, fmt.Sprintf("spec.datasources.%s.plugin", name)); err != nil {
return err
}
}
for i, v := range d.Variables {
if err := validateVariablePlugin(v, fmt.Sprintf("spec.variables[%d]", i)); err != nil {
return err
}
}
for key, panel := range d.Panels {
if panel == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels.%s: panel must not be null", key)
}
path := fmt.Sprintf("spec.panels.%s", key)
if err := validatePanelPlugin(&panel.Spec.Plugin, path+".spec.plugin"); err != nil {
return err
}
panelKind := PanelPluginKind(panel.Spec.Plugin.Kind)
allowed := allowedQueryKinds[panelKind]
for qi := range panel.Spec.Queries {
queryPath := fmt.Sprintf("%s.spec.queries[%d].spec.plugin", path, qi)
if err := validateQueryPlugin(&panel.Spec.Queries[qi].Spec.Plugin, queryPath); err != nil {
return err
}
if err := validateQueryAllowedForPanel(panel.Spec.Queries[qi].Spec.Plugin, allowed, panelKind, queryPath); err != nil {
return err
}
}
}
return nil
}
func validateDatasourcePlugin(plugin *common.Plugin, path string) error {
kind := DatasourcePluginKind(plugin.Kind)
factory, ok := datasourcePluginSpecs[kind]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: unknown datasource plugin kind %q; allowed values: %s", path, kind, formatEnum(kind.Enum()))
}
return validateAndNormalizePluginSpec(plugin, factory, path)
}
func validateVariablePlugin(v dashboard.Variable, path string) error {
switch spec := v.Spec.(type) {
case *dashboard.ListVariableSpec:
pluginPath := path + ".spec.plugin"
kind := VariablePluginKind(spec.Plugin.Kind)
factory, ok := variablePluginSpecs[kind]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: unknown variable plugin kind %q; allowed values: %s", pluginPath, kind, formatEnum(kind.Enum()))
}
return validateAndNormalizePluginSpec(&spec.Plugin, factory, pluginPath)
case *dashboard.TextVariableSpec:
// TextVariables have no plugin, nothing to validate.
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: unsupported variable kind %q", path, v.Kind)
}
}
func validatePanelPlugin(plugin *common.Plugin, path string) error {
kind := PanelPluginKind(plugin.Kind)
factory, ok := panelPluginSpecs[kind]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: unknown panel plugin kind %q; allowed values: %s", path, kind, formatEnum(kind.Enum()))
}
return validateAndNormalizePluginSpec(plugin, factory, path)
}
func validateQueryPlugin(plugin *common.Plugin, path string) error {
kind := QueryPluginKind(plugin.Kind)
factory, ok := queryPluginSpecs[kind]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: unknown query plugin kind %q; allowed values: %s", path, kind, formatEnum(kind.Enum()))
}
return validateAndNormalizePluginSpec(plugin, factory, path)
}
func formatEnum(values []any) string {
parts := make([]string, len(values))
for i, v := range values {
parts[i] = fmt.Sprintf("`%v`", v)
}
return strings.Join(parts, ", ")
}
// validateAndNormalizePluginSpec validates the plugin spec and writes the typed
// struct (with defaults) back into plugin.Spec so that DB storage and API
// responses contain normalized values.
func validateAndNormalizePluginSpec(plugin *common.Plugin, factory func() any, path string) error {
if plugin.Kind == "" {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: plugin kind is required", path)
}
if plugin.Spec == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: plugin spec is required", path)
}
// Re-marshal the spec and unmarshal into the typed struct.
specJSON, err := json.Marshal(plugin.Spec)
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
target := factory()
decoder := json.NewDecoder(bytes.NewReader(specJSON))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
if err := validator.New().Struct(target); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
// Write the typed struct back so defaults are included.
plugin.Spec = target
return nil
}
// validateQueryAllowedForPanel checks that the query plugin kind is permitted
// for the given panel. For composite queries it recurses into sub-queries.
func validateQueryAllowedForPanel(plugin common.Plugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
queryKind := QueryPluginKind(plugin.Kind)
if !slices.Contains(allowed, queryKind) {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: query kind %q is not supported by panel kind %q", path, queryKind, panelKind)
}
// For composite queries, validate each sub-query type.
if queryKind == QueryKindComposite && plugin.Spec != nil {
specJSON, err := json.Marshal(plugin.Spec)
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
var composite struct {
Queries []struct {
Type qb.QueryType `json:"type"`
} `json:"queries"`
}
if err := json.Unmarshal(specJSON, &composite); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
for si, sub := range composite.Queries {
pluginKind, ok := compositeSubQueryTypeToPluginKind[sub.Type]
if !ok {
continue
}
if !slices.Contains(allowed, pluginKind) {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s.spec.queries[%d]: sub-query type %q is not supported by panel kind %q",
path, si, sub.Type, panelKind)
}
}
}
return nil
}

View File

@@ -1,4 +1,4 @@
package dashboardtypesv2
package dashboardtypes
import (
"encoding/json"
@@ -11,37 +11,29 @@ import (
"github.com/stretchr/testify/require"
)
func unmarshalDashboard(data []byte) (*DashboardData, error) {
var d DashboardData
if err := json.Unmarshal(data, &d); err != nil {
return nil, err
}
return &d, nil
}
func TestValidateBigExample(t *testing.T) {
data, err := os.ReadFile("testdata/perses.json")
require.NoError(t, err, "reading example file")
_, err = unmarshalDashboard(data)
_, err = UnmarshalAndValidateDashboardV2JSON(data)
require.NoError(t, err, "expected valid dashboard")
}
func TestValidateDashboardWithSections(t *testing.T) {
data, err := os.ReadFile("testdata/perses_with_sections.json")
require.NoError(t, err, "reading example file")
_, err = unmarshalDashboard(data)
_, err = UnmarshalAndValidateDashboardV2JSON(data)
require.NoError(t, err, "expected valid dashboard")
}
func TestInvalidateNotAJSON(t *testing.T) {
_, err := unmarshalDashboard([]byte("not json"))
_, err := UnmarshalAndValidateDashboardV2JSON([]byte("not json"))
require.Error(t, err, "expected error for invalid JSON")
}
func TestValidateEmptySpec(t *testing.T) {
// no variables no panels
data := []byte(`{}`)
_, err := unmarshalDashboard(data)
_, err := UnmarshalAndValidateDashboardV2JSON(data)
require.NoError(t, err, "expected valid")
}
@@ -67,13 +59,17 @@ func TestValidateOnlyVariables(t *testing.T) {
"kind": "TextVariable",
"spec": {
"name": "mytext",
"value": "default"
"value": "default",
"plugin": {
"kind": "signoz/TextboxVariable",
"spec": {}
}
}
}
],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
_, err := UnmarshalAndValidateDashboardV2JSON(data)
require.NoError(t, err, "expected valid")
}
@@ -152,7 +148,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
@@ -173,7 +169,7 @@ func TestInvalidateOneInvalidPanel(t *testing.T) {
},
"layouts": []
}`)
_, err := unmarshalDashboard(data)
_, err := UnmarshalAndValidateDashboardV2JSON(data)
require.Error(t, err, "expected error for invalid panel plugin kind")
require.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
}
@@ -249,7 +245,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
require.Error(t, err, "expected error for unknown field")
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
@@ -327,7 +323,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
require.Error(t, err, "expected validation error")
if tt.wantContain != "" {
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
@@ -535,7 +531,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
@@ -630,7 +626,7 @@ func TestValidateRequiredFields(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
@@ -652,7 +648,7 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
},
"layouts": []
}`)
d, err := unmarshalDashboard(data)
d, err := UnmarshalAndValidateDashboardV2JSON(data)
require.NoError(t, err, "unmarshal and validate failed")
// After validation+normalization, the plugin spec should be a typed struct.
@@ -699,7 +695,7 @@ func TestNumberPanelDefaults(t *testing.T) {
},
"layouts": []
}`)
d, err := unmarshalDashboard(data)
d, err := UnmarshalAndValidateDashboardV2JSON(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &NumberPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
@@ -720,30 +716,6 @@ func TestNumberPanelDefaults(t *testing.T) {
"expected stored/response JSON to contain operator:>, got: %s", outputStr)
}
// TestPersesFixtureStorageRoundTrip exercises the typed → map[string]any →
// typed cycle that the create/get path performs against the kitchen-sink
// fixture. Catches plugin specs whose UnmarshalJSON expects a different shape
// than the default MarshalJSON emits.
func TestPersesFixtureStorageRoundTrip(t *testing.T) {
raw, err := os.ReadFile("testdata/perses.json")
require.NoError(t, err)
var data DashboardData
require.NoError(t, json.Unmarshal(raw, &data), "initial unmarshal")
marshaled, err := json.Marshal(data)
require.NoError(t, err, "marshal typed → JSON")
var asMap map[string]any
require.NoError(t, json.Unmarshal(marshaled, &asMap), "JSON → map (storage shape)")
remarshaled, err := json.Marshal(asMap)
require.NoError(t, err, "map → JSON (read-back shape)")
var roundtripped DashboardData
require.NoError(t, json.Unmarshal(remarshaled, &roundtripped), "JSON → typed (the failure mode)")
}
// TestStorageRoundTrip simulates the future DB store/load cycle:
// marshal the normalized dashboard to JSON (what would be written to DB),
// then unmarshal it back (what would be read from DB), and verify defaults survive.
@@ -773,7 +745,7 @@ func TestStorageRoundTrip(t *testing.T) {
}`)
// Step 1: Unmarshal + validate + normalize (what the API handler does).
d, err := unmarshalDashboard(input)
d, err := UnmarshalAndValidateDashboardV2JSON(input)
require.NoError(t, err, "unmarshal and validate failed")
// Step 1.5: Verify struct fields have correct defaults (extra validation before storing).
@@ -793,7 +765,7 @@ func TestStorageRoundTrip(t *testing.T) {
require.NoError(t, err, "marshal for storage failed")
// Step 3: Unmarshal from JSON (simulates reading from DB).
loaded, err := unmarshalDashboard(stored)
loaded, err := UnmarshalAndValidateDashboardV2JSON(stored)
require.NoError(t, err, "unmarshal from storage failed")
// Step 3.5: Verify struct fields have correct defaults after loading (before returning in API).
@@ -906,7 +878,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := unmarshalDashboard(tc.data)
_, err := UnmarshalAndValidateDashboardV2JSON(tc.data)
if tc.wantErr {
require.Error(t, err)
} else {

View File

@@ -1,160 +0,0 @@
package dashboardtypesv2
import (
"bytes"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
SchemaVersion = "v6"
MaxTagsPerDashboard = 5
)
type Dashboard struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId"`
Locked bool `json:"locked"`
Info DashboardInfo `json:"info"`
PublicConfig *dashboardtypes.PublicDashboard `json:"publicConfig,omitempty"`
}
// DashboardInfo is the serializable view of a dashboard's contents — what the UI renders as "the dashboard JSON".
type DashboardInfo struct {
StoredDashboardInfo
Tags []*tagtypes.Tag `json:"tags,omitempty"`
}
// StoredDashboardInfo is exactly what serializes into the dashboard.data column.
type StoredDashboardInfo struct {
Metadata DashboardMetadata `json:"metadata"`
Data DashboardData `json:"data"`
}
type DashboardMetadata struct {
SchemaVersion string `json:"schemaVersion"`
Image string `json:"image,omitempty"`
UploadedGrafana bool `json:"uploadedGrafana"`
}
type PostableDashboard struct {
StoredDashboardInfo
Tags []tagtypes.PostableTag `json:"tags,omitempty"`
}
func (p *PostableDashboard) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias PostableDashboard
var tmp alias
if err := dec.Decode(&tmp); err != nil {
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*p = PostableDashboard(tmp)
return p.Validate()
}
func (p *PostableDashboard) Validate() error {
if p.Metadata.SchemaVersion != SchemaVersion {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "metadata.schemaVersion must be %q, got %q", SchemaVersion, p.Metadata.SchemaVersion)
}
if p.Data.Display == nil || p.Data.Display.Name == "" {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "data.display.name is required")
}
if len(p.Tags) > MaxTagsPerDashboard {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "a dashboard can have at most %d tags", MaxTagsPerDashboard)
}
return p.Data.Validate()
}
type GettableDashboard struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId"`
Locked bool `json:"locked"`
Info GettableDashboardInfo `json:"info"`
PublicConfig *dashboardtypes.GettablePublicDasbhboard `json:"publicConfig,omitempty"`
}
type GettableDashboardInfo struct {
StoredDashboardInfo
Tags []*tagtypes.GettableTag `json:"tags,omitempty"`
}
func NewGettableDashboardFromDashboard(dashboard *Dashboard) *GettableDashboard {
gettable := &GettableDashboard{
Identifiable: dashboard.Identifiable,
TimeAuditable: dashboard.TimeAuditable,
UserAuditable: dashboard.UserAuditable,
OrgID: dashboard.OrgID,
Locked: dashboard.Locked,
Info: GettableDashboardInfo{
StoredDashboardInfo: dashboard.Info.StoredDashboardInfo,
Tags: tagtypes.NewGettableTagsFromTags(dashboard.Info.Tags),
},
}
if dashboard.PublicConfig != nil {
gettable.PublicConfig = dashboardtypes.NewGettablePublicDashboard(dashboard.PublicConfig)
}
return gettable
}
func NewDashboard(orgID valuer.UUID, createdBy string, postable PostableDashboard, resolvedTags []*tagtypes.Tag) *Dashboard {
now := time.Now()
return &Dashboard{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
UserAuditable: types.UserAuditable{CreatedBy: createdBy, UpdatedBy: createdBy},
OrgID: orgID,
Locked: false,
Info: DashboardInfo{
StoredDashboardInfo: StoredDashboardInfo{
Metadata: postable.Metadata,
Data: postable.Data,
},
Tags: resolvedTags,
},
}
}
// ToStorableDashboard packages a Dashboard into the bun row that goes into
// the dashboard table. Tags are intentionally omitted — they live in
// tag_relations and are inserted separately by the caller.
func (d *Dashboard) ToStorableDashboard() (*dashboardtypes.StorableDashboard, error) {
data, err := d.Info.toStorableDashboardData()
if err != nil {
return nil, err
}
return &dashboardtypes.StorableDashboard{
Identifiable: types.Identifiable{ID: d.ID},
TimeAuditable: d.TimeAuditable,
UserAuditable: d.UserAuditable,
OrgID: d.OrgID,
Locked: d.Locked,
Data: data,
}, nil
}
func (s StoredDashboardInfo) toStorableDashboardData() (dashboardtypes.StorableDashboardData, error) {
raw, err := json.Marshal(s)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal v2 dashboard data")
}
out := dashboardtypes.StorableDashboardData{}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal v2 dashboard data")
}
return out, nil
}

View File

@@ -1,116 +0,0 @@
package dashboardtypesv2
import (
"bytes"
"encoding/json"
"fmt"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
v1 "github.com/perses/perses/pkg/model/api/v1"
"github.com/perses/perses/pkg/model/api/v1/common"
)
// DashboardData is the SigNoz dashboard v2 spec shape. It mirrors
// v1.DashboardSpec (Perses) field-for-field, except every common.Plugin
// occurrence is replaced with a typed SigNoz plugin whose OpenAPI schema is a
// per-site discriminated oneOf.
type DashboardData struct {
Display *common.Display `json:"display,omitempty"`
Datasources map[string]*DatasourceSpec `json:"datasources,omitempty"`
Variables []Variable `json:"variables,omitempty"`
Panels map[string]*Panel `json:"panels"`
Layouts []Layout `json:"layouts"`
Duration common.DurationString `json:"duration"`
RefreshInterval common.DurationString `json:"refreshInterval,omitempty"`
Links []v1.Link `json:"links,omitempty"`
}
// ══════════════════════════════════════════════
// Unmarshal + validate entry point
// ══════════════════════════════════════════════
func (d *DashboardData) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias DashboardData
var tmp alias
if err := dec.Decode(&tmp); err != nil {
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*d = DashboardData(tmp)
return d.Validate()
}
// ══════════════════════════════════════════════
// Cross-field validation
// ══════════════════════════════════════════════
func (d *DashboardData) Validate() error {
for key, panel := range d.Panels {
if panel == nil {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "spec.panels.%s: panel must not be null", key)
}
path := fmt.Sprintf("spec.panels.%s", key)
panelKind := panel.Spec.Plugin.Kind
allowed := allowedQueryKinds[panelKind]
for qi, q := range panel.Spec.Queries {
queryPath := fmt.Sprintf("%s.spec.queries[%d].spec.plugin", path, qi)
if err := validateQueryAllowedForPanel(q.Spec.Plugin, allowed, panelKind, queryPath); err != nil {
return err
}
}
}
return nil
}
func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
if !slices.Contains(allowed, plugin.Kind) {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput,
"%s: query kind %q is not supported by panel kind %q", path, plugin.Kind, panelKind)
}
if plugin.Kind != QueryKindComposite {
return nil
}
composite, ok := plugin.Spec.(*CompositeQuerySpec)
if !ok || composite == nil {
return nil
}
specJSON, err := json.Marshal(composite)
if err != nil {
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "%s.spec", path)
}
var subs struct {
Queries []struct {
Type qb.QueryType `json:"type"`
} `json:"queries"`
}
if err := json.Unmarshal(specJSON, &subs); err != nil {
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "%s.spec", path)
}
for si, sub := range subs.Queries {
subKind, ok := compositeSubQueryTypeToPluginKind[sub.Type]
if !ok {
continue
}
if !slices.Contains(allowed, subKind) {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput,
"%s.spec.queries[%d]: sub-query type %q is not supported by panel kind %q",
path, si, sub.Type, panelKind)
}
}
return nil
}
var (
compositeSubQueryTypeToPluginKind = map[qb.QueryType]QueryPluginKind{
qb.QueryTypeBuilder: QueryKindBuilder,
qb.QueryTypeFormula: QueryKindFormula,
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
qb.QueryTypePromQL: QueryKindPromQL,
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
}
)

View File

@@ -1,171 +0,0 @@
package dashboardtypesv2
// TestDashboardDataMatchesPerses asserts that DashboardData
// and every nested SigNoz-owned type cover the JSON field set of their Perses
// counterpart.
import (
"reflect"
"sort"
"strings"
"testing"
v1 "github.com/perses/perses/pkg/model/api/v1"
"github.com/perses/perses/pkg/model/api/v1/dashboard"
"github.com/stretchr/testify/assert"
)
func TestDashboardDataMatchesPerses(t *testing.T) {
cases := []struct {
name string
ours reflect.Type
perses reflect.Type
}{
{"DashboardSpec", typeOf[DashboardData](), typeOf[v1.DashboardSpec]()},
{"Panel", typeOf[Panel](), typeOf[v1.Panel]()},
{"PanelSpec", typeOf[PanelSpec](), typeOf[v1.PanelSpec]()},
{"Query", typeOf[Query](), typeOf[v1.Query]()},
{"QuerySpec", typeOf[QuerySpec](), typeOf[v1.QuerySpec]()},
{"DatasourceSpec", typeOf[DatasourceSpec](), typeOf[v1.DatasourceSpec]()},
{"Variable", typeOf[Variable](), typeOf[dashboard.Variable]()},
{"ListVariableSpec", typeOf[ListVariableSpec](), typeOf[dashboard.ListVariableSpec]()},
{"TextVariableSpec", typeOf[TextVariableSpec](), typeOf[dashboard.TextVariableSpec]()},
{"Layout", typeOf[Layout](), typeOf[dashboard.Layout]()},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
missing, extra := drift(c.ours, c.perses)
assert.Empty(t, missing,
"DashboardData (%s) is missing json fields present on Perses %s — upstream likely added or renamed a field",
c.ours.Name(), c.perses.Name())
assert.Empty(t, extra,
"DashboardData (%s) has json fields absent on Perses %s — upstream likely removed a field or we added one without the counterpart",
c.ours.Name(), c.perses.Name())
})
}
}
func TestDriftDetectionMechanics(t *testing.T) {
t.Run("upstream added a field", func(t *testing.T) {
type ours struct {
Name string `json:"name"`
}
type perses struct {
Name string `json:"name"`
Description string `json:"description"`
}
missing, extra := drift(typeOf[ours](), typeOf[perses]())
assert.Equal(t, []string{"description"}, missing, "missing fires: upstream has a field we don't")
assert.Empty(t, extra)
})
t.Run("upstream removed a field", func(t *testing.T) {
type ours struct {
Name string `json:"name"`
Description string `json:"description"`
}
type perses struct {
Name string `json:"name"`
}
missing, extra := drift(typeOf[ours](), typeOf[perses]())
assert.Empty(t, missing)
assert.Equal(t, []string{"description"}, extra, "extra fires: we kept a field upstream removed")
})
t.Run("upstream renamed a field", func(t *testing.T) {
type ours struct {
Name string `json:"name"`
}
type perses struct {
Name string `json:"title"`
}
missing, extra := drift(typeOf[ours](), typeOf[perses]())
assert.Equal(t, []string{"title"}, missing, "missing fires for the new name")
assert.Equal(t, []string{"name"}, extra, "extra fires for the old name — both fire on a rename")
})
t.Run("we added a field upstream does not have", func(t *testing.T) {
type ours struct {
Name string `json:"name"`
Internal string `json:"internal"`
}
type perses struct {
Name string `json:"name"`
}
missing, extra := drift(typeOf[ours](), typeOf[perses]())
assert.Empty(t, missing)
assert.Equal(t, []string{"internal"}, extra, "extra fires: we added a field with no upstream counterpart")
})
t.Run("embedded struct flattens — drift inside the embed is caught", func(t *testing.T) {
type embedded struct {
Display string `json:"display"`
NewBit string `json:"newBit"` // upstream added this inside the embed
}
type ours struct {
Display string `json:"display"`
Name string `json:"name"`
}
type perses struct {
embedded `json:",inline"`
Name string `json:"name"`
}
missing, extra := drift(typeOf[ours](), typeOf[perses]())
assert.Equal(t, []string{"newBit"}, missing, "field added inside an inlined embed surfaces at the parent level")
assert.Empty(t, extra)
})
}
func drift(ours, perses reflect.Type) (missing, extra []string) {
o, p := jsonFields(ours), jsonFields(perses)
return sortedDiff(p, o), sortedDiff(o, p)
}
// jsonFields returns the set of json tag names for a struct, flattening
// anonymous embedded fields (matching encoding/json behavior).
func jsonFields(t reflect.Type) map[string]struct{} {
out := map[string]struct{}{}
if t.Kind() != reflect.Struct {
return out
}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
// Skip unexported fields (e.g., dashboard.TextVariableSpec has an
// unexported `variableSpec` interface tag).
if !f.IsExported() && !f.Anonymous {
continue
}
tag := f.Tag.Get("json")
name := strings.Split(tag, ",")[0]
// Anonymous embed with empty json name (no tag, or `json:",inline"` /
// `json:",omitempty"`-style options-only tag) is flattened by encoding/json.
if f.Anonymous && name == "" {
for k := range jsonFields(f.Type) {
out[k] = struct{}{}
}
continue
}
if tag == "-" || name == "" {
continue
}
out[name] = struct{}{}
}
return out
}
// sortedDiff returns keys in a but not in b, sorted.
func sortedDiff(a, b map[string]struct{}) []string {
var diff []string
for k := range a {
if _, ok := b[k]; !ok {
diff = append(diff, k)
}
}
sort.Strings(diff)
return diff
}
func typeOf[T any]() reflect.Type { return reflect.TypeOf((*T)(nil)).Elem() }

View File

@@ -1,301 +0,0 @@
package dashboardtypesv2
import (
"bytes"
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/go-playground/validator/v10"
"github.com/swaggest/jsonschema-go"
)
// ══════════════════════════════════════════════
// Panel plugin
// ══════════════════════════════════════════════
type PanelPlugin struct {
Kind PanelPluginKind `json:"kind"`
Spec any `json:"spec"`
}
// PrepareJSONSchema drops the reflected struct shape (type: object, properties)
// from the envelope so that only the JSONSchemaOneOf result binds.
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (p *PanelPlugin) UnmarshalJSON(data []byte) error {
kind, specJSON, err := extractKindAndSpec(data)
if err != nil {
return err
}
factory, ok := panelPluginSpecs[PanelPluginKind(kind)]
if !ok {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "unknown panel plugin kind %q", kind)
}
spec, err := decodeSpec(specJSON, factory(), kind)
if err != nil {
return err
}
p.Kind = PanelPluginKind(kind)
p.Spec = spec
return nil
}
func (PanelPlugin) JSONSchemaOneOf() []any {
return []any{
PanelPluginVariant[TimeSeriesPanelSpec]{Kind: string(PanelKindTimeSeries)},
PanelPluginVariant[BarChartPanelSpec]{Kind: string(PanelKindBarChart)},
PanelPluginVariant[NumberPanelSpec]{Kind: string(PanelKindNumber)},
PanelPluginVariant[PieChartPanelSpec]{Kind: string(PanelKindPieChart)},
PanelPluginVariant[TablePanelSpec]{Kind: string(PanelKindTable)},
PanelPluginVariant[HistogramPanelSpec]{Kind: string(PanelKindHistogram)},
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
}
}
type PanelPluginVariant[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v PanelPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return restrictKindToOneValue(s, v.Kind)
}
// ══════════════════════════════════════════════
// Query plugin
// ══════════════════════════════════════════════
type QueryPlugin struct {
Kind QueryPluginKind `json:"kind"`
Spec any `json:"spec"`
}
func (QueryPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (p *QueryPlugin) UnmarshalJSON(data []byte) error {
kind, specJSON, err := extractKindAndSpec(data)
if err != nil {
return err
}
factory, ok := queryPluginSpecs[QueryPluginKind(kind)]
if !ok {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "unknown query plugin kind %q", kind)
}
spec, err := decodeSpec(specJSON, factory(), kind)
if err != nil {
return err
}
p.Kind = QueryPluginKind(kind)
p.Spec = spec
return nil
}
func (QueryPlugin) JSONSchemaOneOf() []any {
return []any{
QueryPluginVariant[BuilderQuerySpec]{Kind: string(QueryKindBuilder)},
QueryPluginVariant[CompositeQuerySpec]{Kind: string(QueryKindComposite)},
QueryPluginVariant[FormulaSpec]{Kind: string(QueryKindFormula)},
QueryPluginVariant[PromQLQuerySpec]{Kind: string(QueryKindPromQL)},
QueryPluginVariant[ClickHouseSQLQuerySpec]{Kind: string(QueryKindClickHouseSQL)},
QueryPluginVariant[TraceOperatorSpec]{Kind: string(QueryKindTraceOperator)},
}
}
type QueryPluginVariant[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v QueryPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return restrictKindToOneValue(s, v.Kind)
}
// ══════════════════════════════════════════════
// Variable plugin
// ══════════════════════════════════════════════
type VariablePlugin struct {
Kind VariablePluginKind `json:"kind"`
Spec any `json:"spec"`
}
func (VariablePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (p *VariablePlugin) UnmarshalJSON(data []byte) error {
kind, specJSON, err := extractKindAndSpec(data)
if err != nil {
return err
}
factory, ok := variablePluginSpecs[VariablePluginKind(kind)]
if !ok {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "unknown variable plugin kind %q", kind)
}
spec, err := decodeSpec(specJSON, factory(), kind)
if err != nil {
return err
}
p.Kind = VariablePluginKind(kind)
p.Spec = spec
return nil
}
func (VariablePlugin) JSONSchemaOneOf() []any {
return []any{
VariablePluginVariant[DynamicVariableSpec]{Kind: string(VariableKindDynamic)},
VariablePluginVariant[QueryVariableSpec]{Kind: string(VariableKindQuery)},
VariablePluginVariant[CustomVariableSpec]{Kind: string(VariableKindCustom)},
}
}
type VariablePluginVariant[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return restrictKindToOneValue(s, v.Kind)
}
// ══════════════════════════════════════════════
// Datasource plugin
// ══════════════════════════════════════════════
type DatasourcePlugin struct {
Kind DatasourcePluginKind `json:"kind"`
Spec any `json:"spec"`
}
func (DatasourcePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (p *DatasourcePlugin) UnmarshalJSON(data []byte) error {
kind, specJSON, err := extractKindAndSpec(data)
if err != nil {
return err
}
factory, ok := datasourcePluginSpecs[DatasourcePluginKind(kind)]
if !ok {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "unknown datasource plugin kind %q", kind)
}
spec, err := decodeSpec(specJSON, factory(), kind)
if err != nil {
return err
}
p.Kind = DatasourcePluginKind(kind)
p.Spec = spec
return nil
}
func (DatasourcePlugin) JSONSchemaOneOf() []any {
return []any{
DatasourcePluginVariant[struct{}]{Kind: string(DatasourceKindSigNoz)},
}
}
type DatasourcePluginVariant[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v DatasourcePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return restrictKindToOneValue(s, v.Kind)
}
// ══════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════
var (
panelPluginSpecs = map[PanelPluginKind]func() any{
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
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) },
}
queryPluginSpecs = map[QueryPluginKind]func() any{
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
QueryKindComposite: func() any { return new(CompositeQuerySpec) },
QueryKindFormula: func() any { return new(FormulaSpec) },
QueryKindPromQL: func() any { return new(PromQLQuerySpec) },
QueryKindClickHouseSQL: func() any { return new(ClickHouseSQLQuerySpec) },
QueryKindTraceOperator: func() any { return new(TraceOperatorSpec) },
}
variablePluginSpecs = map[VariablePluginKind]func() any{
VariableKindDynamic: func() any { return new(DynamicVariableSpec) },
VariableKindQuery: func() any { return new(QueryVariableSpec) },
VariableKindCustom: func() any { return new(CustomVariableSpec) },
}
datasourcePluginSpecs = map[DatasourcePluginKind]func() any{
DatasourceKindSigNoz: func() any { return new(struct{}) },
}
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder},
}
)
// extractKindAndSpec parses a {"kind": "...", "spec": {...}} envelope and returns
// kind and the raw spec bytes for typed decoding.
func extractKindAndSpec(data []byte) (string, []byte, error) {
var head struct {
Kind string `json:"kind"`
Spec json.RawMessage `json:"spec"`
}
if err := json.Unmarshal(data, &head); err != nil {
return "", nil, err
}
return head.Kind, head.Spec, nil
}
// decodeSpec strict-decodes a spec JSON into target and runs struct-tag validation (go-playground/validator).
func decodeSpec(specJSON []byte, target any, kind string) (any, error) {
if len(specJSON) == 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "kind %q: spec is required", kind)
}
dec := json.NewDecoder(bytes.NewReader(specJSON))
dec.DisallowUnknownFields()
if err := dec.Decode(target); err != nil {
return nil, errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "kind %q: invalid spec JSON", kind)
}
if err := validator.New().Struct(target); err != nil {
return nil, errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "kind %q: spec failed validation", kind)
}
return target, nil
}
// clearOneOfParentShape drops Type and Properties on a schema that also has a JSONSchemaOneOf.
func clearOneOfParentShape(s *jsonschema.Schema) error {
s.Type = nil
s.Properties = nil
return nil
}
// restrictKindToOneValue ensures that the schema only allows one Kind value for a type.
// For eg. PanelPluginVariant[TimeSeriesPanelSpec]{Kind: string(PanelKindTimeSeries)} should
// only allow "signoz/TimeSeriesPanel" in its kind field.
func restrictKindToOneValue(schema *jsonschema.Schema, kind string) error {
kindProp, ok := schema.Properties["kind"]
if !ok || kindProp.TypeObject == nil {
return errors.NewInternalf(errors.CodeInternal, "variant schema missing `kind` property")
}
kindProp.TypeObject.WithEnum(kind)
schema.Properties["kind"] = kindProp
return nil
}

View File

@@ -1,189 +0,0 @@
package dashboardtypesv2
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
v1 "github.com/perses/perses/pkg/model/api/v1"
"github.com/perses/perses/pkg/model/api/v1/common"
"github.com/perses/perses/pkg/model/api/v1/dashboard"
"github.com/perses/perses/pkg/model/api/v1/variable"
"github.com/swaggest/jsonschema-go"
)
// ══════════════════════════════════════════════
// Datasource
// ══════════════════════════════════════════════
type DatasourceSpec struct {
Display *common.Display `json:"display,omitempty"`
Default bool `json:"default"`
Plugin DatasourcePlugin `json:"plugin"`
}
// ══════════════════════════════════════════════
// Panel
// ══════════════════════════════════════════════
type Panel struct {
Kind string `json:"kind"`
Spec PanelSpec `json:"spec"`
}
type PanelSpec struct {
Display *v1.PanelDisplay `json:"display,omitempty"`
Plugin PanelPlugin `json:"plugin"`
Queries []Query `json:"queries,omitempty"`
Links []v1.Link `json:"links,omitempty"`
}
// ══════════════════════════════════════════════
// Query
// ══════════════════════════════════════════════
type Query struct {
Kind string `json:"kind"`
Spec QuerySpec `json:"spec"`
}
type QuerySpec struct {
Name string `json:"name,omitempty"`
Plugin QueryPlugin `json:"plugin"`
}
// ══════════════════════════════════════════════
// Variable
// ══════════════════════════════════════════════
// Variable is the list/text sum type. Spec is set to *ListVariableSpec or
// *TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
// discriminated oneOf (see JSONSchemaOneOf).
type Variable struct {
Kind variable.Kind `json:"kind"`
Spec any `json:"spec"`
}
func (Variable) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (v *Variable) UnmarshalJSON(data []byte) error {
kind, specJSON, err := extractKindAndSpec(data)
if err != nil {
return err
}
switch kind {
case string(variable.KindList):
spec, err := decodeSpec(specJSON, new(ListVariableSpec), kind)
if err != nil {
return err
}
v.Kind = variable.KindList
v.Spec = spec
case string(variable.KindText):
spec, err := decodeSpec(specJSON, new(TextVariableSpec), kind)
if err != nil {
return err
}
v.Kind = variable.KindText
v.Spec = spec
default:
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "unknown variable kind %q", kind)
}
return nil
}
func (Variable) JSONSchemaOneOf() []any {
return []any{
VariableEnvelope[ListVariableSpec]{Kind: string(variable.KindList)},
VariableEnvelope[TextVariableSpec]{Kind: string(variable.KindText)},
}
}
type VariableEnvelope[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v VariableEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return restrictKindToOneValue(s, v.Kind)
}
// ListVariableSpec mirrors dashboard.ListVariableSpec (variable.ListSpec
// fields + Name) but with a typed VariablePlugin replacing common.Plugin.
type ListVariableSpec struct {
Display *variable.Display `json:"display,omitempty"`
DefaultValue *variable.DefaultValue `json:"defaultValue,omitempty"`
AllowAllValue bool `json:"allowAllValue"`
AllowMultiple bool `json:"allowMultiple"`
CustomAllValue string `json:"customAllValue,omitempty"`
CapturingRegexp string `json:"capturingRegexp,omitempty"`
Sort *variable.Sort `json:"sort,omitempty"`
Plugin VariablePlugin `json:"plugin"`
Name string `json:"name"`
}
// TextVariableSpec mirrors dashboard.TextVariableSpec (variable.TextSpec +
// Name). No plugin.
type TextVariableSpec struct {
Display *variable.Display `json:"display,omitempty"`
Value string `json:"value"`
Constant bool `json:"constant,omitempty"`
Name string `json:"name"`
}
// ══════════════════════════════════════════════
// Layout
// ══════════════════════════════════════════════
// Layout is the dashboard layout sum type. Spec is populated by UnmarshalJSON
// with the concrete layout spec struct (today only dashboard.GridLayoutSpec)
// 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"`
Spec any `json:"spec"`
}
// layoutSpecs is the layout sum type factory. Perses only defines
// KindGridLayout today; adding a new kind upstream surfaces as an
// "unknown layout kind" runtime error here until we add it.
var layoutSpecs = map[dashboard.LayoutKind]func() any{
dashboard.KindGridLayout: func() any { return new(dashboard.GridLayoutSpec) },
}
func (Layout) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (l *Layout) UnmarshalJSON(data []byte) error {
kind, specJSON, err := extractKindAndSpec(data)
if err != nil {
return err
}
factory, ok := layoutSpecs[dashboard.LayoutKind(kind)]
if !ok {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "unknown layout kind %q", kind)
}
spec, err := decodeSpec(specJSON, factory(), kind)
if err != nil {
return err
}
l.Kind = dashboard.LayoutKind(kind)
l.Spec = spec
return nil
}
func (Layout) JSONSchemaOneOf() []any {
return []any{
LayoutEnvelope[dashboard.GridLayoutSpec]{Kind: string(dashboard.KindGridLayout)},
}
}
type LayoutEnvelope[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v LayoutEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return restrictKindToOneValue(s, v.Kind)
}

View File

@@ -0,0 +1,30 @@
package dashboardtypes
import (
_ "embed"
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
//go:embed ai_o11y_overview.json
var aiO11yOverviewJSON []byte
func NewDefaultSystemDashboard(orgID valuer.UUID, source Source) (*Dashboard, error) {
switch source {
case SourceAIO11yOverview:
return newDefaultAIO11yOverview(orgID)
default:
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no defaults registered for system dashboard source %s", source.StringValue())
}
}
func newDefaultAIO11yOverview(orgID valuer.UUID) (*Dashboard, error) {
data := StorableDashboardData{}
if err := json.Unmarshal(aiO11yOverviewJSON, &data); err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to unmarshal embedded ai-o11y-overview default")
}
return NewSystemDashboard(orgID, SourceAIO11yOverview.StringValue(), data)
}

View File

@@ -1,15 +1,13 @@
package dashboardtypesv2
package dashboardtypes
import (
"encoding/json"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
)
// ══════════════════════════════════════════════
@@ -22,10 +20,11 @@ const (
VariableKindDynamic VariablePluginKind = "signoz/DynamicVariable"
VariableKindQuery VariablePluginKind = "signoz/QueryVariable"
VariableKindCustom VariablePluginKind = "signoz/CustomVariable"
VariableKindTextbox VariablePluginKind = "signoz/TextboxVariable"
)
func (VariablePluginKind) Enum() []any {
return []any{VariableKindDynamic, VariableKindQuery, VariableKindCustom}
return []any{VariableKindDynamic, VariableKindQuery, VariableKindCustom, VariableKindTextbox}
}
type DynamicVariableSpec struct {
@@ -43,6 +42,8 @@ type CustomVariableSpec struct {
CustomValue string `json:"customValue" validate:"required" required:"true"`
}
type TextboxVariableSpec struct{}
// ══════════════════════════════════════════════
// SigNoz query plugin specs — aliased from querybuildertypesv5
// ══════════════════════════════════════════════
@@ -80,36 +81,12 @@ type BuilderQuerySpec struct {
func (b *BuilderQuerySpec) UnmarshalJSON(data []byte) error {
spec, err := qb.UnmarshalBuilderQueryBySignal(data)
if err != nil {
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid builder query spec")
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid builder query spec")
}
b.Spec = spec
return nil
}
// MarshalJSON delegates to the inner Spec so the on-wire shape matches what
// UnmarshalJSON expects (a flat builder-query payload with `signal` at the top
// level). Without this, Go's default would wrap it as {"Spec": {...}} and the
// signal-dispatch on read would fail.
func (b BuilderQuerySpec) MarshalJSON() ([]byte, error) {
return json.Marshal(b.Spec)
}
// PrepareJSONSchema drops the reflected struct shape so only the
// JSONSchemaOneOf result binds.
func (BuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
// JSONSchemaOneOf exposes the three signal-dispatched shapes a builder query
// can take. Mirrors qb.UnmarshalBuilderQueryBySignal's runtime dispatch.
func (BuilderQuerySpec) JSONSchemaOneOf() []any {
return []any{
qb.QueryBuilderQuery[qb.LogAggregation]{},
qb.QueryBuilderQuery[qb.MetricAggregation]{},
qb.QueryBuilderQuery[qb.TraceAggregation]{},
}
}
// ══════════════════════════════════════════════
// SigNoz panel plugin specs
// ══════════════════════════════════════════════
@@ -295,7 +272,7 @@ func (t TimePreference) MarshalJSON() ([]byte, error) {
func (t *TimePreference) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid timePreference: must be a string, one of `global_time`, `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`")
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid timePreference: must be a string, one of `global_time`, `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`")
}
if v == "" {
*t = TimePreferenceGlobalTime
@@ -307,7 +284,7 @@ func (t *TimePreference) UnmarshalJSON(data []byte) error {
*t = tp
return nil
default:
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid timePreference %q: must be `global_time`, `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`", v)
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid timePreference %q: must be `global_time`, `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`", v)
}
}
@@ -336,7 +313,7 @@ func (l LegendPosition) MarshalJSON() ([]byte, error) {
func (l *LegendPosition) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid legend position: must be a string, one of `bottom` or `right`")
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid legend position: must be a string, one of `bottom` or `right`")
}
if v == "" {
*l = LegendPositionBottom
@@ -348,7 +325,7 @@ func (l *LegendPosition) UnmarshalJSON(data []byte) error {
*l = lp
return nil
default:
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid legend position %q: must be `bottom` or `right`", v)
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid legend position %q: must be `bottom` or `right`", v)
}
}
@@ -377,7 +354,7 @@ func (f ThresholdFormat) MarshalJSON() ([]byte, error) {
func (f *ThresholdFormat) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid threshold format: must be a string, one of `text` or `background`")
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid threshold format: must be a string, one of `text` or `background`")
}
if v == "" {
*f = ThresholdFormatText
@@ -389,7 +366,7 @@ func (f *ThresholdFormat) UnmarshalJSON(data []byte) error {
*f = tf
return nil
default:
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid threshold format %q: must be `text` or `background`", v)
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid threshold format %q: must be `text` or `background`", v)
}
}
@@ -429,7 +406,7 @@ func (o ComparisonOperator) MarshalJSON() ([]byte, error) {
func (o *ComparisonOperator) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid comparison operator: must be a string, one of `>`, `<`, `>=`, `<=`, `=`, `above`, `below`, `above_or_equal`, `below_or_equal`, `equal`, or `not_equal`")
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid comparison operator: must be a string, one of `>`, `<`, `>=`, `<=`, `=`, `above`, `below`, `above_or_equal`, `below_or_equal`, `equal`, or `not_equal`")
}
if v == "" {
*o = ComparisonOperatorGT
@@ -443,7 +420,7 @@ func (o *ComparisonOperator) UnmarshalJSON(data []byte) error {
*o = co
return nil
default:
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid comparison operator %q: must be `>`, `<`, `>=`, `<=`, `=`, `above`, `below`, `above_or_equal`, `below_or_equal`, `equal`, or `not_equal`", v)
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid comparison operator %q: must be `>`, `<`, `>=`, `<=`, `=`, `above`, `below`, `above_or_equal`, `below_or_equal`, `equal`, or `not_equal`", v)
}
}
@@ -474,7 +451,7 @@ func (li LineInterpolation) MarshalJSON() ([]byte, error) {
func (li *LineInterpolation) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid line interpolation: must be a string, one of `linear`, `spline`, `step_after`, or `step_before`")
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid line interpolation: must be a string, one of `linear`, `spline`, `step_after`, or `step_before`")
}
if v == "" {
*li = LineInterpolationSpline
@@ -486,7 +463,7 @@ func (li *LineInterpolation) UnmarshalJSON(data []byte) error {
*li = val
return nil
default:
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid line interpolation %q: must be `linear`, `spline`, `step_after`, or `step_before`", v)
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid line interpolation %q: must be `linear`, `spline`, `step_after`, or `step_before`", v)
}
}
@@ -515,7 +492,7 @@ func (ls LineStyle) MarshalJSON() ([]byte, error) {
func (ls *LineStyle) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid line style: must be a string, one of `solid` or `dashed`")
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid line style: must be a string, one of `solid` or `dashed`")
}
if v == "" {
*ls = LineStyleSolid
@@ -527,7 +504,7 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
*ls = val
return nil
default:
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid line style %q: must be `solid` or `dashed`", v)
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid line style %q: must be `solid` or `dashed`", v)
}
}
@@ -557,7 +534,7 @@ func (fm FillMode) MarshalJSON() ([]byte, error) {
func (fm *FillMode) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid fill mode: must be a string, one of `solid`, `gradient`, or `none`")
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid fill mode: must be a string, one of `solid`, `gradient`, or `none`")
}
if v == "" {
*fm = FillModeSolid
@@ -569,7 +546,7 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
*fm = val
return nil
default:
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid fill mode %q: must be `solid`, `gradient`, or `none`", v)
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid fill mode %q: must be `solid`, `gradient`, or `none`", v)
}
}
@@ -616,12 +593,12 @@ func (p *PrecisionOption) UnmarshalJSON(data []byte) error {
p.String = valuer.NewString(strconv.Itoa(n))
return nil
default:
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid precision option %d: must be `0`, `1`, `2`, `3`, `4`, or `full`", n)
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid precision option %d: must be `0`, `1`, `2`, `3`, `4`, or `full`", n)
}
}
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid precision option: must be `0`, `1`, `2`, `3`, `4`, or `full`")
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid precision option: must be `0`, `1`, `2`, `3`, `4`, or `full`")
}
if v == "" {
*p = PrecisionOption2
@@ -633,6 +610,6 @@ func (p *PrecisionOption) UnmarshalJSON(data []byte) error {
*p = val
return nil
default:
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid precision option %q: must be `0`, `1`, `2`, `3`, `4`, or `full`", v)
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid precision option %q: must be `0`, `1`, `2`, `3`, `4`, or `full`", v)
}
}

View File

@@ -0,0 +1,44 @@
package dashboardtypes
import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Source struct {
valuer.String
}
var (
SourceAIO11yOverview = Source{valuer.NewString("ai-o11y-overview")}
)
var AllSources = []Source{
SourceAIO11yOverview,
}
func NewSource(s string) (Source, error) {
switch s {
case SourceAIO11yOverview.StringValue():
return SourceAIO11yOverview, nil
default:
return Source{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid system dashboard source: %s", s)
}
}
func (source *Source) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
s, err := NewSource(str)
if err != nil {
return err
}
*source = s
return nil
}

View File

@@ -13,6 +13,8 @@ type Store interface {
Get(context.Context, valuer.UUID, valuer.UUID) (*StorableDashboard, error)
GetBySource(context.Context, valuer.UUID, string) (*StorableDashboard, error)
GetPublic(context.Context, string) (*StorablePublicDashboard, error)
GetDashboardByOrgsAndPublicID(context.Context, []string, string) (*StorableDashboard, error)
@@ -25,10 +27,14 @@ type Store interface {
Update(context.Context, valuer.UUID, *StorableDashboard) error
UpdateBySource(context.Context, valuer.UUID, string, *StorableDashboard) error
UpdatePublic(context.Context, *StorablePublicDashboard) error
Delete(context.Context, valuer.UUID, valuer.UUID) error
DeleteBySource(context.Context, valuer.UUID, string) error
DeletePublic(context.Context, string) error
RunInTx(context.Context, func(context.Context) error) error

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