Compare commits

..

1 Commits

Author SHA1 Message Date
Gaurav Tewari
b7962bac4d feat: update datepicker interaction 2026-08-10 18:05:58 +05:30
44 changed files with 10700 additions and 3080 deletions

View File

@@ -376,19 +376,7 @@ function App(): JSX.Element {
tracesSampleRate: 0, // Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
beforeSend(event, hint) {
const error = hint?.originalException as
| { name?: string; code?: string | number }
| undefined;
// Ignore benign aborted/cancelled requests (axios + fetch).
if (error?.code === 'ERR_CANCELED' || error?.code === 'ECONNABORTED') {
return null;
}
if (error?.name === 'AbortError') {
return null;
}
beforeSend(event) {
// Drop the event if its level is 'warning' or 'info'
if (event.level === 'warning' || event.level === 'info') {
return null;

View File

@@ -0,0 +1,31 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/resetPassword';
/**
* @deprecated Use the generated `useResetPassword` hook (or `resetPassword` fetcher) from
* `api/generated/services/users` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const resetPassword = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>(`/resetPassword`, {
...props,
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default resetPassword;

View File

@@ -84,6 +84,45 @@
color: rgba($color: var(--l1-foreground), $alpha: 0.4);
}
}
&.invalid-flash {
animation:
timeSelection-input-shake 300ms ease-out,
timeSelection-input-invalid-flash 1200ms ease-out;
input {
background-color: transparent;
}
}
}
@keyframes timeSelection-input-shake {
0%,
100% {
transform: translateX(0);
}
25%,
75% {
transform: translateX(5px);
}
50% {
transform: translateX(-5px);
}
}
@keyframes timeSelection-input-invalid-flash {
0%,
50% {
background-color: color-mix(in srgb, var(--bg-cherry-500) 18%, transparent);
}
}
@media (prefers-reduced-motion: reduce) {
.timeSelection-input.invalid-flash {
animation: none;
}
}
.valid-format-error {

View File

@@ -1,9 +1,10 @@
import { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import dayjs from 'dayjs';
import * as timeUtils from 'utils/timeUtils';
import CustomTimePicker from './CustomTimePicker';
import { INVALID_FLASH_DURATION_MS } from './useInvalidFlash';
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
@@ -285,4 +286,102 @@ describe('CustomTimePicker', () => {
expect((input as HTMLInputElement).value).toBe('Live');
});
describe('invalid entry flash', () => {
const FLASH_START_DELAY_MS = 50;
const enterInvalidRange = (input: HTMLElement): void => {
fireEvent.focus(input);
fireEvent.change(input, {
target: { value: '10/08/2026 14:30 - 10/08/2026 15:30' },
});
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
};
const getFieldWrapper = (input: HTMLElement): HTMLElement =>
input.closest('.timeSelection-input') as HTMLElement;
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('flashes the field, then clears the flash on its own', () => {
render(<Wrapper />);
const input = screen.getByRole('textbox');
enterInvalidRange(input);
act(() => {
jest.advanceTimersByTime(FLASH_START_DELAY_MS);
});
expect(getFieldWrapper(input)).toHaveClass('invalid-flash');
act(() => {
jest.advanceTimersByTime(INVALID_FLASH_DURATION_MS);
});
expect(getFieldWrapper(input)).not.toHaveClass('invalid-flash');
});
it('flashes again on a second consecutive invalid entry', () => {
render(<Wrapper />);
const input = screen.getByRole('textbox');
enterInvalidRange(input);
act(() => {
jest.advanceTimersByTime(FLASH_START_DELAY_MS + INVALID_FLASH_DURATION_MS);
});
expect(getFieldWrapper(input)).not.toHaveClass('invalid-flash');
enterInvalidRange(input);
act(() => {
jest.advanceTimersByTime(FLASH_START_DELAY_MS);
});
expect(getFieldWrapper(input)).toHaveClass('invalid-flash');
});
it('drops the error state when closing restores the previous value', () => {
const onError = jest.fn();
render(<Wrapper onError={onError} />);
const input = screen.getByRole('textbox');
enterInvalidRange(input);
act(() => {
jest.advanceTimersByTime(FLASH_START_DELAY_MS);
});
expect(getFieldWrapper(input)).toHaveClass('error');
// Chevron close without an intervening blur takes the branch that reverts
// the input to the previously applied range
fireEvent.click(
document.querySelector('.time-input-suffix-icon-badge') as HTMLElement,
);
expect(getFieldWrapper(input)).not.toHaveClass('error');
expect((input as HTMLInputElement).value).toBe(
'2024-01-01 00:00:00 - 2024-01-01 01:00:00',
);
expect(onError).toHaveBeenLastCalledWith(false);
});
it('keeps the persistent error styling after the flash has gone', () => {
render(<Wrapper />);
const input = screen.getByRole('textbox');
enterInvalidRange(input);
act(() => {
jest.advanceTimersByTime(FLASH_START_DELAY_MS + INVALID_FLASH_DURATION_MS);
});
expect(getFieldWrapper(input)).toHaveClass('error');
expect(getFieldWrapper(input)).not.toHaveClass('invalid-flash');
});
});
});

View File

@@ -30,6 +30,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
import { TimeRangeValidationResult, validateTimeRange } from 'utils/timeUtils';
import CustomTimePickerPopoverContent from './CustomTimePickerPopoverContent';
import { useInvalidFlash } from './useInvalidFlash';
import './CustomTimePicker.styles.scss';
@@ -106,6 +107,7 @@ function CustomTimePicker({
const [inputErrorDetails, setInputErrorDetails] = useState<
TimeRangeValidationResult['errorDetails'] | null
>(null);
const { isFlashing, triggerFlash } = useInvalidFlash();
const location = useLocation();
const inputRef = useRef<InputRef>(null);
@@ -275,6 +277,9 @@ function CustomTimePicker({
if (!newOpen) {
setCustomDTPickerVisible?.(false);
setActiveView('datetime');
// The rejected value is being discarded in favour of the previous one, so
// the error it raised must not outlive it
resetErrorStatus();
if (showLiveLogs) {
setSelectedTimePlaceholderValue('Live');
@@ -340,6 +345,7 @@ function CustomTimePicker({
if (minTime && (!minTime.isValid() || minTime < maxAllowedMinTime)) {
setInputStatus(CustomTimePickerInputStatus.ERROR);
triggerFlash();
onError(true);
setInputErrorDetails({
message: `Please enter time less than ${maxAllowedMinTimeInMonths} months`,
@@ -392,6 +398,7 @@ function CustomTimePicker({
if (!isValidTimeRange) {
setInputStatus(CustomTimePickerInputStatus.ERROR);
triggerFlash();
onError(true);
setInputErrorDetails(errorDetails || null);
return;
@@ -485,6 +492,9 @@ function CustomTimePicker({
setOpen(false);
setCustomDTPickerVisible?.(false);
// The rejected value is being discarded in favour of the previous one, so
// the error it raised must not outlive it
resetErrorStatus();
if (showLiveLogs) {
setInputValue('Live');
@@ -600,6 +610,7 @@ function CustomTimePicker({
className={cx(
'timeSelection-input',
inputStatus === CustomTimePickerInputStatus.ERROR ? 'error' : '',
isFlashing ? 'invalid-flash' : '',
)}
type="text"
status={

View File

@@ -0,0 +1,47 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export const INVALID_FLASH_DURATION_MS = 1200;
interface UseInvalidFlashResult {
isFlashing: boolean;
triggerFlash: () => void;
}
/**
* Drives a one-shot "invalid input" flash that clears itself after
* `durationMs`, leaving any persistent error styling to the caller.
*
* A re-trigger drops the flag for one frame before setting it again: a CSS
* animation only restarts when the class is genuinely removed and re-added, so
* without that gap a second failed attempt in a row would not animate.
*/
export function useInvalidFlash(
durationMs: number = INVALID_FLASH_DURATION_MS,
): UseInvalidFlashResult {
const [isFlashing, setIsFlashing] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const frameRef = useRef<number>();
const clearPending = useCallback((): void => {
if (timeoutRef.current !== undefined) {
clearTimeout(timeoutRef.current);
}
if (frameRef.current !== undefined) {
cancelAnimationFrame(frameRef.current);
}
}, []);
useEffect(() => clearPending, [clearPending]);
const triggerFlash = useCallback((): void => {
clearPending();
setIsFlashing(false);
frameRef.current = requestAnimationFrame(() => {
setIsFlashing(true);
timeoutRef.current = setTimeout(() => setIsFlashing(false), durationMs);
});
}, [clearPending, durationMs]);
return { isFlashing, triggerFlash };
}

View File

@@ -5,9 +5,10 @@ import {
useCreateResetPasswordToken,
useDeleteUser,
useGetResetPasswordToken,
useCreateUserRole,
useDeleteUserRole,
useGetRolesByUserID,
useGetUser,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
useUpdateMyUserV2,
useUpdateUser,
} from 'api/generated/services/users';
@@ -24,14 +25,15 @@ import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
jest.mock('api/generated/services/users', () => ({
useDeleteUser: jest.fn(),
useGetUser: jest.fn(),
useDeleteUserRole: jest.fn(),
useGetRolesByUserID: jest.fn(),
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
useUpdateUser: jest.fn(),
useUpdateMyUserV2: jest.fn(),
useCreateUserRole: jest.fn(),
useSetRoleByUserID: jest.fn(),
useGetResetPasswordToken: jest.fn(),
useCreateResetPasswordToken: jest.fn(),
getGetUserQueryKey: ({ id }: { id: string }): string[] => [
`/api/v2/users/${id}`,
getGetRolesByUserIDQueryKey: ({ id }: { id: string }): string[] => [
`/api/v2/users/${id}/roles`,
],
}));
@@ -192,7 +194,11 @@ describe('EditMemberDrawer', () => {
isLoading: false,
refetch: jest.fn(),
});
(useDeleteUserRole as jest.Mock).mockReturnValue({
(useGetRolesByUserID as jest.Mock).mockReturnValue({
data: { data: [managedRoles[0]] },
isLoading: false,
});
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
mutateAsync: mockRemoveMutateAsync.mockResolvedValue({}),
isLoading: false,
});
@@ -204,7 +210,7 @@ describe('EditMemberDrawer', () => {
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useCreateUserRole as jest.Mock).mockReturnValue({
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
@@ -306,12 +312,12 @@ describe('EditMemberDrawer', () => {
expect(onClose).not.toHaveBeenCalled();
});
it('adding a new role creates a user role without removing existing ones', async () => {
it('adding a new role calls setRole without removing existing ones', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockSet = jest.fn().mockResolvedValue({});
(useCreateUserRole as jest.Mock).mockReturnValue({
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: mockSet,
isLoading: false,
});
@@ -328,14 +334,15 @@ describe('EditMemberDrawer', () => {
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith({
data: { userId: 'user-1', roleId: managedRoles[1].id },
pathParams: { id: 'user-1' },
data: { name: 'signoz-editor' },
});
expect(mockRemoveMutateAsync).not.toHaveBeenCalled();
expect(onComplete).toHaveBeenCalled();
});
});
it('deselecting a role deletes the user role by its assignment id', async () => {
it('deselecting a role calls removeRole with the role id', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -354,7 +361,7 @@ describe('EditMemberDrawer', () => {
await waitFor(() => {
expect(mockRemoveMutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'ur-1' },
pathParams: { id: 'user-1', roleId: managedRoles[0].id },
});
expect(onComplete).toHaveBeenCalled();
});

View File

@@ -1,46 +0,0 @@
.highlights {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px 16px;
padding: 12px 0;
// Constrain each KeyValueLabel (the grid items) to its cell.
:global(.key-value-label) {
width: auto;
min-width: 0;
overflow: hidden;
}
}
.valueBadge {
--badge-font-size: 13px;
box-sizing: border-box;
max-width: 100%;
min-width: 0;
}
// Truncating text inside a badge
.badgeText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.serviceDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-forest);
flex-shrink: 0;
margin-right: 4px;
}
.traceLink {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent-primary);
}

View File

@@ -1,36 +0,0 @@
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { ILog } from 'types/api/logs/log';
import { LOG_HIGHLIGHTS } from './config';
import styles from './LogHighlights.module.scss';
interface LogHighlightsProps {
log: ILog;
}
function LogHighlights({ log }: LogHighlightsProps): JSX.Element | null {
const fields = LOG_HIGHLIGHTS.map((field) => ({
key: field.key,
label: field.label,
value: field.render(log),
})).filter((field) => field.value != null);
if (fields.length === 0) {
return null;
}
return (
<div className={styles.highlights} data-testid="log-details-highlights">
{fields.map((field) => (
<KeyValueLabel
key={field.key}
badgeKey={field.label}
badgeValue={field.value}
direction="column"
/>
))}
</div>
);
}
export default LogHighlights;

View File

@@ -1,23 +0,0 @@
import { Link } from 'react-router-dom';
import styles from './LogHighlights.module.scss';
interface TraceIdFieldProps {
traceId: string;
}
function TraceIdField({ traceId }: TraceIdFieldProps): JSX.Element {
return (
<Link
to={{ pathname: `/trace/${traceId}` }}
target="_blank"
rel="noreferrer"
className={styles.traceLink}
title={traceId}
>
{traceId}
</Link>
);
}
export default TraceIdField;

View File

@@ -1,102 +0,0 @@
import { ReactNode } from 'react';
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
import { ILog } from 'types/api/logs/log';
import styles from './LogHighlights.module.scss';
import TraceIdField from './TraceIdField';
// Severity badge color mirrors the LogStateIndicator bar
const SEVERITY_COLOR: Record<string, BadgeColor> = {
[LogType.TRACE]: 'forest',
[LogType.DEBUG]: 'aqua',
[LogType.INFO]: 'robin',
[LogType.WARN]: 'amber',
[LogType.ERROR]: 'cherry',
[LogType.FATAL]: 'sakura',
};
export interface LogHighlightConfig {
key: string;
label: string;
render: (log: ILog) => ReactNode | null;
}
// Resource/attribute lookup (keys like `service.name` live in resources_string,
// occasionally attributes_string). Typed loosely as these are string maps.
const getAttr = (log: ILog, key: string): string =>
(log.resources_string as unknown as Record<string, string>)?.[key] ||
(log.attributes_string as unknown as Record<string, string>)?.[key] ||
'';
const valueBadge = (
value: string,
options?: { prefix?: ReactNode; color?: BadgeColor },
): ReactNode => (
<Badge color={options?.color ?? 'vanilla'} className={styles.valueBadge}>
{options?.prefix}
<span className={styles.badgeText} title={value}>
{value}
</span>
</Badge>
);
export const LOG_HIGHLIGHTS: LogHighlightConfig[] = [
{
key: 'service',
label: 'SERVICE',
render: (log): ReactNode | null => {
const value = getAttr(log, 'service.name');
return value
? valueBadge(value, {
prefix: <span className={styles.serviceDot} />,
})
: null;
},
},
{
key: 'severity',
label: 'SEVERITY',
render: (log): ReactNode | null => {
if (!log.severity_text) {
return null;
}
return valueBadge(log.severity_text, {
color: SEVERITY_COLOR[getLogIndicatorType(log)] ?? 'vanilla',
});
},
},
{
key: 'namespace',
label: 'NAMESPACE',
render: (log): ReactNode | null => {
const value = getAttr(log, 'service.namespace');
return value ? valueBadge(value) : null;
},
},
{
key: 'environment',
label: 'ENVIRONMENT',
render: (log): ReactNode | null => {
const value = getAttr(log, 'deployment.environment');
return value ? valueBadge(value) : null;
},
},
{
key: 'traceId',
label: 'TRACE ID',
render: (log): ReactNode | null => {
const traceId = log.trace_id || log.traceId;
return traceId ? <TraceIdField traceId={traceId} /> : null;
},
},
{
key: 'spanId',
label: 'SPAN ID',
render: (log): ReactNode | null => {
const spanId = log.span_id || log.spanID;
return spanId ? valueBadge(spanId) : null;
},
},
];

View File

@@ -115,45 +115,6 @@ describe('LogDetail drawer — header (isLogDetailsV2)', () => {
expect(screen.queryByText('Open in Explorer')).not.toBeInTheDocument();
});
it('renders Highlights for fields present on the log, omitting absent ones', () => {
const logWithMeta = {
...mockLog,
severity_text: 'ERROR',
trace_id: 'trace-abc',
resources_string: {
'service.name': 'checkout',
'deployment.environment': 'production',
},
} as unknown as ILog;
renderDrawer({ log: logWithMeta });
const highlights = screen.getByTestId('log-details-highlights');
expect(highlights).toHaveTextContent('SEVERITY');
expect(highlights).toHaveTextContent('ERROR');
expect(highlights).toHaveTextContent('SERVICE');
expect(highlights).toHaveTextContent('checkout');
expect(highlights).toHaveTextContent('ENVIRONMENT');
expect(highlights).toHaveTextContent('production');
expect(highlights).toHaveTextContent('TRACE ID');
// Absent fields are omitted (no namespace / span id on this log).
expect(highlights).not.toHaveTextContent('NAMESPACE');
expect(highlights).not.toHaveTextContent('SPAN ID');
});
it('links the trace id highlight to the trace detail in a new tab', () => {
const logWithTrace = {
...mockLog,
trace_id: 'trace-abc',
} as unknown as ILog;
renderDrawer({ log: logWithTrace });
const link = screen.getByRole('link', { name: 'trace-abc' });
expect(link).toHaveAttribute('target', '_blank');
expect(link.getAttribute('href')).toContain('/trace/trace-abc');
});
it('navigates to the next / previous log with the Down / Up arrow keys', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logs = [makeLog('log-0'), makeLog('log-1'), makeLog('log-2')];

View File

@@ -55,7 +55,6 @@ import { isLogDetailsV2, RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
import LogHighlights from './LogHighlights/LogHighlights';
import './LogDetails.styles.scss';
@@ -400,8 +399,6 @@ function LogDetailInner({
<div className="log-overflow-shadow">&nbsp;</div>
</div>
{isLogDetailsV2 && <LogHighlights log={log} />}
<div className="tabs-and-search">
<ToggleGroupSimple
type="single"

View File

@@ -183,14 +183,15 @@ function QuerySearch({
isProgrammaticChangeRef.current = true;
}
const changes = view.state.changes({
from: 0,
to: currentValue.length,
insert: value,
});
view.dispatch({
changes,
selection: { anchor: changes.newLength },
changes: {
from: 0,
to: currentValue.length,
insert: value,
},
selection: {
anchor: value.length,
},
});
},
[],

View File

@@ -301,66 +301,6 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
dispatchSpy.mockRestore();
});
it('does not crash when the expression contains CRLF line breaks (issue #5869)', async () => {
const dispatchSpy = jest.spyOn(EditorView.prototype, 'dispatch');
const onChange = jest.fn() as jest.MockedFunction<(v: string) => void>;
const initialExpression = "service.name = 'frontend'";
// Filtering on a multi-line log value (CRLF) used to throw
// "RangeError: Selection points outside of document".
const crlfExpression = "body CONTAINS 'line1\r\nline2\r\nline3'";
const baseQueryData = {
...initialQueriesMap.logs.builder.queryData[0],
filter: { expression: initialExpression },
};
const { rerender } = render(
<QuerySearch
onChange={onChange}
queryData={baseQueryData}
dataSource={DataSource.LOGS}
/>,
);
await waitFor(
() => {
const editorContent = document.querySelector(
CM_EDITOR_SELECTOR,
) as HTMLElement;
expect(editorContent.textContent || '').toBe(initialExpression);
},
{ timeout: 3000 },
);
rerender(
<QuerySearch
onChange={onChange}
queryData={{ ...baseQueryData, filter: { expression: crlfExpression } }}
dataSource={DataSource.LOGS}
/>,
);
// The programmatic replace dispatched without throwing, and the selection anchor
// stayed within the CRLF-normalized document (the bug set it past the end).
await waitFor(() => {
const spec = dispatchSpy.mock.calls
.map(
(call) =>
call[0] as {
selection?: { anchor?: number };
changes?: { newLength?: number };
},
)
.find((s) => s?.selection?.anchor != null && s?.changes?.newLength != null);
expect(spec).toBeDefined();
expect(spec?.selection?.anchor).toBeLessThanOrEqual(
spec?.changes?.newLength as number,
);
});
dispatchSpy.mockRestore();
});
it('fetches key suggestions for metrics even without aggregateAttribute.key when showFilterSuggestionsWithoutMetric is true', async () => {
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions

View File

@@ -22,7 +22,6 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { useQueryState } from 'nuqs';
import { DataSource } from 'types/common/queryBuilder';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
@@ -117,7 +116,6 @@ function EntityEventsContent({
: newUserExpression || '',
);
if (validation.isValid) {
saveRecentQueryByExpression(DataSource.LOGS, newUserExpression);
querySearchOnRun(newUserExpression || '');
void logEvent(InfraMonitoringEvents.FilterApplied, {

View File

@@ -29,7 +29,6 @@ import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { ILog } from 'types/api/logs/log';
import { DataSource } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/queryValidationUtils';
@@ -133,7 +132,6 @@ function EntityLogsContent({
);
if (validation.isValid) {
saveRecentQueryByExpression(DataSource.LOGS, newUserExpression);
querySearchOnRun(newUserExpression);
void logEvent(InfraMonitoringEvents.FilterApplied, {

View File

@@ -22,7 +22,6 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { useQueryState } from 'nuqs';
import { DataSource } from 'types/common/queryBuilder';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
@@ -99,7 +98,6 @@ function EntityTracesContent({
: newUserExpression || '',
);
if (validation.isValid) {
saveRecentQueryByExpression(DataSource.TRACES, newUserExpression);
querySearchOnRun(newUserExpression || '');
void logEvent(InfraMonitoringEvents.FilterApplied, {

View File

@@ -2,7 +2,6 @@ import { useCallback } from 'react';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { DataSource } from 'types/common/queryBuilder';
import { MetricsSearchProps } from './types';
@@ -24,14 +23,12 @@ function MetricsSearch({
);
const handleStageAndRunQuery = useCallback(() => {
saveRecentQueryByExpression(DataSource.METRICS, currentQueryFilterExpression);
onChange(currentQueryFilterExpression);
onRunQuery?.();
}, [currentQueryFilterExpression, onChange, onRunQuery]);
const handleRunQuery = useCallback(
(expression: string): void => {
saveRecentQueryByExpression(DataSource.METRICS, expression);
setCurrentQueryFilterExpression(expression);
onChange(expression);
},

View File

@@ -33,7 +33,7 @@ jest.mock('hooks/useNotifications', () => ({
}),
}));
const RESET_PASSWORD_ENDPOINT = '*/api/v2/factor_password/reset';
const RESET_PASSWORD_ENDPOINT = '*/resetPassword';
const mockHistoryPush = history.push as jest.MockedFunction<
typeof history.push

View File

@@ -1,12 +1,11 @@
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { Form, Input as AntdInput } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useResetPassword } from 'api/generated/services/users';
import resetPasswordApi from 'api/v1/factor_password/resetPassword';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
import ROUTES from 'constants/routes';
@@ -15,6 +14,7 @@ import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { ArrowRight, CircleAlert, KeyRound } from '@signozhq/icons';
import { Label } from 'pages/SignUp/styles';
import APIError from 'types/api/error';
import { FormContainer } from './styles';
@@ -26,41 +26,40 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
const [confirmPasswordError, setConfirmPasswordError] =
useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<APIError | null>();
const [isValidPassword, setIsValidPassword] = useState(false);
const [loading, setLoading] = useState(false);
const { t } = useTranslation(['common']);
const { search } = useLocation();
const params = new URLSearchParams(search);
const token = params.get('token');
const { notifications } = useNotifications();
const {
mutate: resetPassword,
isLoading,
error: mutationError,
} = useResetPassword();
const errorMessage = useMemo(
() => convertToApiError(mutationError),
[mutationError],
);
const [form] = Form.useForm<FormValues>();
const handleFormSubmit = (): void => {
const { password } = form.getFieldsValue();
const handleFormSubmit: () => Promise<void> = async () => {
try {
setLoading(true);
setErrorMessage(null);
const { password } = form.getFieldsValue();
resetPassword(
{ data: { password, token: token || '' } },
{
onSuccess: (): void => {
notifications.success({
message: t('success', {
ns: 'common',
}),
});
history.push(ROUTES.LOGIN);
},
},
);
await resetPasswordApi({
password,
token: token || '',
});
notifications.success({
message: t('success', {
ns: 'common',
}),
});
history.push(ROUTES.LOGIN);
setLoading(false);
} catch (error) {
setLoading(false);
setErrorMessage(error as APIError);
}
};
const validatePassword = (): boolean => {
@@ -223,7 +222,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
color="primary"
type="submit"
data-attr="reset-password"
disabled={!isValidPassword || isLoading}
disabled={!isValidPassword || loading}
className="reset-password-submit-button"
suffix={<ArrowRight size={16} />}
>

View File

@@ -1,22 +1,19 @@
import { useCallback, useMemo } from 'react';
import type {
AuthtypesGettableRoleDTO,
AuthtypesUserRoleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useQueryClient } from 'react-query';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import {
useCreateUserRole,
useDeleteUserRole,
useGetUser,
getGetRolesByUserIDQueryKey,
useGetRolesByUserID,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
} from 'api/generated/services/users';
import { retryOn429 } from 'utils/errorUtils';
const enum PromiseStatus {
Fulfilled = 'fulfilled',
Rejected = 'rejected',
}
// Stable identity so the memos below do not recompute on every render.
const EMPTY_USER_ROLES: AuthtypesUserRoleDTO[] = [];
export interface MemberRoleUpdateFailure {
roleName: string;
error: unknown;
@@ -36,30 +33,30 @@ export function useMemberRoleManager(
userId: string,
enabled: boolean,
): UseMemberRoleManagerResult {
const { data, isLoading } = useGetUser(
const queryClient = useQueryClient();
const { data, isLoading } = useGetRolesByUserID(
{ id: userId },
{ query: { enabled: !!userId && enabled } },
);
const userRoles = data?.data?.userRoles ?? EMPTY_USER_ROLES;
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
() => userRoles.map((userRole) => userRole.role),
[userRoles],
() => data?.data ?? [],
[data?.data],
);
// DELETE /api/v2/user_roles/{id} is keyed by the user_role join row, not the role.
const assignmentIdByRoleId = useMemo(
() => new Map(userRoles.map((userRole) => [userRole.roleId, userRole.id])),
[userRoles],
);
const { mutateAsync: setRole } = useSetRoleByUserID({
mutation: { retry: retryOn429 },
});
const { mutateAsync: removeRole } = useRemoveUserRoleByUserIDAndRoleID({
mutation: { retry: retryOn429 },
});
const { mutateAsync: createUserRole } = useCreateUserRole({
mutation: { retry: retryOn429 },
});
const { mutateAsync: deleteUserRole } = useDeleteUserRole({
mutation: { retry: retryOn429 },
});
const invalidateRoles = useCallback(
() =>
queryClient.invalidateQueries(getGetRolesByUserIDQueryKey({ id: userId })),
[userId, queryClient],
);
const applyDiff = useCallback(
async (
@@ -83,33 +80,30 @@ export function useMemberRoleManager(
const allOperations = [
...addedRoles.map((role) => ({
role,
run: (): ReturnType<typeof createUserRole> =>
createUserRole({ data: { userId, roleId: role.id ?? '' } }),
run: (): ReturnType<typeof setRole> =>
setRole({
pathParams: { id: userId },
data: { name: role.name ?? '' },
}),
})),
...removedRoles.map((role) => ({
role,
run: (): ReturnType<typeof removeRole> =>
removeRole({ pathParams: { id: userId, roleId: role.id ?? '' } }),
})),
...removedRoles
.map((role) => ({
role,
assignmentId: assignmentIdByRoleId.get(role.id ?? ''),
}))
.filter(
(
entry,
): entry is {
role: AuthtypesGettableRoleDTO;
assignmentId: string;
} => !!entry.assignmentId,
)
.map(({ role, assignmentId }) => ({
role,
run: (): ReturnType<typeof deleteUserRole> =>
deleteUserRole({ pathParams: { id: assignmentId } }),
})),
];
const results = await Promise.allSettled(
allOperations.map((op) => op.run()),
);
const successCount = results.filter(
(r) => r.status === PromiseStatus.Fulfilled,
).length;
if (successCount > 0) {
await invalidateRoles();
}
const failures: MemberRoleUpdateFailure[] = [];
results.forEach((result, index) => {
if (result.status === PromiseStatus.Rejected) {
@@ -119,6 +113,7 @@ export function useMemberRoleManager(
error: result.reason,
onRetry: async (): Promise<void> => {
await run();
await invalidateRoles();
},
});
}
@@ -126,7 +121,7 @@ export function useMemberRoleManager(
return failures;
},
[userId, currentRoles, assignmentIdByRoleId, createUserRole, deleteUserRole],
[userId, currentRoles, setRole, removeRole, invalidateRoles],
);
return { currentRoles, isLoading, applyDiff };

View File

@@ -19,30 +19,6 @@ type CompositeWithBuilder = {
builder?: { queryData?: IBuilderQuery[] };
};
export function saveRecentQueryByExpression(
dataSource: IBuilderQuery['dataSource'],
expression: string | null | undefined,
source = '',
): void {
const trimmed = expression?.trim();
if (!trimmed) {
return;
}
const validation = validateQuery(trimmed);
if (!validation.isValid) {
return;
}
const signal = toSignal(dataSource);
if (!signal) {
return;
}
store.save({
signal,
source,
filter: { expression: trimmed },
});
}
// Persists each builder query in the composite as a recent entry. Call this
// only from explicit user-driven Run triggers — reacting to stagedQuery or any
// other derived state pollutes recents with navigation/refresh/go-to traffic.
@@ -55,10 +31,22 @@ export function saveRecentQuery(
}
queryData.forEach((q) => {
saveRecentQueryByExpression(
q.dataSource,
q.filter?.expression,
q.source ?? '',
);
const expression = q.filter?.expression?.trim();
if (!expression) {
return;
}
const validation = validateQuery(expression);
if (!validation.isValid) {
return;
}
const signal = toSignal(q.dataSource);
if (!signal) {
return;
}
store.save({
signal,
source: q.source ?? '',
filter: q.filter ?? { expression: '' },
});
});
}

View File

@@ -0,0 +1,9 @@
export interface Props {
token: string;
password: string;
}
export interface PayloadProps {
data: string;
status: string;
}

View File

@@ -0,0 +1,78 @@
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { validateTimeRange } from 'utils/timeUtils';
const FORMAT = DATE_TIME_FORMATS.UK_DATETIME_SECONDS;
const TIMEZONE = 'Africa/Lagos';
const inTimezone = (offsetMinutes: number): string =>
dayjs().tz(TIMEZONE).subtract(offsetMinutes, 'minute').format(FORMAT);
describe('validateTimeRange', () => {
it('accepts a well formed past range', () => {
const result = validateTimeRange(
inTimezone(120),
inTimezone(60),
FORMAT,
TIMEZONE,
);
expect(result.isValid).toBe(true);
expect(result.startTimeMs).toBeLessThan(result.endTimeMs as number);
});
it.each([
['missing seconds', '10/08/2026 14:30'],
['date only', '10/08/2026'],
['empty string', ''],
['unparseable text', 'garbage'],
['truncated minutes', '10/08/2026 14:3'],
])('rejects %s without throwing', (_label, startTime) => {
let result;
expect(() => {
result = validateTimeRange(startTime, inTimezone(60), FORMAT, TIMEZONE);
}).not.toThrow();
expect(result).toMatchObject({
isValid: false,
errorDetails: { code: 'INVALID_DATE_TIME_FORMAT' },
});
});
it('rejects a missing end time instead of defaulting it to now', () => {
const result = validateTimeRange(
inTimezone(60),
undefined as unknown as string,
FORMAT,
TIMEZONE,
);
expect(result.isValid).toBe(false);
expect(result.errorDetails?.code).toBe('INVALID_DATE_TIME_FORMAT');
});
it('rejects future dates', () => {
const result = validateTimeRange(
inTimezone(-120),
inTimezone(-60),
FORMAT,
TIMEZONE,
);
expect(result.isValid).toBe(false);
expect(result.errorDetails?.code).toBe('DATES_IN_THE_FUTURE');
});
it('rejects a range where start is not before end', () => {
const result = validateTimeRange(
inTimezone(60),
inTimezone(120),
FORMAT,
TIMEZONE,
);
expect(result.isValid).toBe(false);
expect(result.errorDetails?.code).toBe('START_TIME_AFTER_END_TIME');
});
});

View File

@@ -1,5 +1,5 @@
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import dayjs, { Dayjs } from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
@@ -220,6 +220,23 @@ export interface TimeRangeValidationResult {
endTimeMs?: number;
}
const safeParseInTimezone = (
value: string,
format: string,
timezone: string,
): Dayjs | null => {
if (!value || !dayjs(value, format).isValid()) {
return null;
}
try {
const parsed = dayjs.tz(value, format, timezone);
return parsed.isValid() ? parsed : null;
} catch {
return null;
}
};
/**
* Validates a start and end datetime string.
*
@@ -243,14 +260,12 @@ export const validateTimeRange = (
format: string,
timezone: string,
): TimeRangeValidationResult => {
const start = dayjs.tz(startTime, format, timezone);
const end = dayjs.tz(endTime, format, timezone);
const start = safeParseInTimezone(startTime, format, timezone);
const end = safeParseInTimezone(endTime, format, timezone);
const now = dayjs().tz(timezone);
const startTimeMs = start.valueOf();
const endTimeMs = end.valueOf();
// Invalid format or parsing failure
if (!start.isValid() || !end.isValid()) {
if (!start || !end) {
return {
isValid: false,
errorDetails: {
@@ -270,6 +285,9 @@ Shortcuts:
};
}
const startTimeMs = start.valueOf();
const endTimeMs = end.valueOf();
// dates must not be in the future
if (start.isAfter(now) || end.isAfter(now)) {
return {

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-1,.cls-2,.cls-3{fill-rule:evenodd;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}</style></defs><title>Icon_24px_SQL_Color</title><g data-name="Product Icons"><g ><polygon class="cls-1" points="4.67 10.44 4.67 13.45 12 17.35 12 14.34 4.67 10.44"/><polygon class="cls-1" points="4.67 15.09 4.67 18.1 12 22 12 18.99 4.67 15.09"/><polygon class="cls-2" points="12 17.35 19.33 13.45 19.33 10.44 12 14.34 12 17.35"/><polygon class="cls-2" points="12 22 19.33 18.1 19.33 15.09 12 18.99 12 22"/><polygon class="cls-3" points="19.33 8.91 19.33 5.9 12 2 12 5.01 19.33 8.91"/><polygon class="cls-2" points="12 2 4.67 5.9 4.67 8.91 12 5.01 12 2"/><polygon class="cls-1" points="4.67 5.87 4.67 8.89 12 12.79 12 9.77 4.67 5.87"/><polygon class="cls-2" points="12 12.79 19.33 8.89 19.33 5.87 12 9.77 12 12.79"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 933 B

View File

@@ -1,136 +0,0 @@
{
"id": "cloudsql_mysql",
"title": "GCP Cloud SQL for MySQL",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "cloudsql.googleapis.com/database/up",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/instance_state",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/replication/replica_lag",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/cpu/utilization",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/memory/utilization",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/disk/utilization",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/network/connections",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/queries",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/dml_operations_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/threads",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/innodb/buffer_pool_reads_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/innodb/buffer_pool_read_requests_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/slow_queries_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/aborted_connects_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/innodb/deadlocks_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/innodb/row_lock_waits_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/disk/read_ops_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/disk/write_ops_count",
"unit": "Count",
"type": "Sum",
"description": ""
}
],
"logs": []
},
"telemetryCollectionStrategy": {
"gcp": {}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "GCP Cloud SQL for MySQL Overview",
"description": "Overview of GCP Cloud SQL for MySQL metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,3 +0,0 @@
### Monitor GCP Cloud SQL for MySQL with SigNoz
Collect key GCP Cloud SQL for MySQL metrics and view them with an out of the box dashboard.

View File

@@ -784,57 +784,40 @@
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/CompositeQuery",
"kind": "signoz/BuilderQuery",
"spec": {
"queries": [
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"type": "builder_query",
"spec": {
"name": "A",
"stepInterval": 0,
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "cloudsql.googleapis.com/database/cpu/utilization",
"temporality": "",
"timeAggregation": "max",
"spaceAggregation": "max",
"reduceTo": ""
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND database_id in $database_id AND gcp.resource_type = 'cloudsql_database' "
},
"groupBy": [
{
"name": "database_id",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": null,
"selectFields": null,
"secondaryAggregations": null,
"functions": null,
"legend": "{{database_id}}"
}
},
{
"type": "builder_formula",
"spec": {
"name": "F1",
"expression": "100 * A",
"disabled": false,
"order": null,
"functions": null,
"legend": "{{database_id}}"
}
"metricName": "cloudsql.googleapis.com/database/cpu/utilization",
"temporality": "",
"timeAggregation": "max",
"spaceAggregation": "max",
"reduceTo": "avg"
}
]
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND database_id in $database_id AND gcp.resource_type = 'cloudsql_database' "
},
"groupBy": [
{
"name": "database_id",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{database_id}}"
}
}
}
@@ -1434,4 +1417,4 @@
"refreshInterval": "",
"links": []
}
}
}

View File

@@ -245,13 +245,11 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
}
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
// Read the storable, not the decoded v2 dashboard: deleting must work even
// when the stored data is corrupt or never migrated off the v1 schema.
storable, err := module.store.Get(ctx, orgID, id)
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return err
}
if err := storable.ErrIfNotDeletable(); err != nil {
if err := existing.ErrIfNotDeletable(); err != nil {
return err
}

View File

@@ -46,7 +46,6 @@ var (
GCPServiceComputeEngine = ServiceID{valuer.NewString("computeengine")}
GCPServiceGKE = ServiceID{valuer.NewString("gke")}
GCPServiceCloudStorage = ServiceID{valuer.NewString("cloudstorage")}
GCPServiceCloudSQLMySQL = ServiceID{valuer.NewString("cloudsql_mysql")}
)
func (ServiceID) Enum() []any {
@@ -83,7 +82,6 @@ func (ServiceID) Enum() []any {
GCPServiceComputeEngine,
GCPServiceGKE,
GCPServiceCloudStorage,
GCPServiceCloudSQLMySQL,
}
}
@@ -126,7 +124,6 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
GCPServiceComputeEngine,
GCPServiceGKE,
GCPServiceCloudStorage,
GCPServiceCloudSQLMySQL,
},
}

View File

@@ -201,18 +201,6 @@ func (storableDashboardData *StorableDashboardData) GetWidgetIds() []string {
return widgetIds
}
// ErrIfNotDeletable gates deletion on the columns alone, never on Data, so a
// dashboard whose data is corrupt or stuck on the v1 schema stays deletable.
func (storable StorableDashboard) ErrIfNotDeletable() error {
if storable.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot delete a locked dashboard, please unlock the dashboard to delete")
}
if !storable.Source.isUserDeletable() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be deleted", storable.Source)
}
return nil
}
func (dashboard *Dashboard) ErrIfNotMutable() error {
if dashboard.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")

View File

@@ -4,7 +4,6 @@ import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
)
@@ -82,64 +81,3 @@ func TestCanUpdate_MultipleDeletions_ByDiff(t *testing.T) {
})
}
}
func TestStorableDashboardErrIfNotDeletable(t *testing.T) {
testCases := []struct {
subtestName string
locked bool
source Source
data StorableDashboardData
expectDeletable bool
}{
{
subtestName: "user dashboard on the v2 schema",
source: SourceUser,
data: StorableDashboardData{"metadata": map[string]any{"schemaVersion": SchemaVersion}},
expectDeletable: true,
},
{
subtestName: "user dashboard still on the v1 schema",
source: SourceUser,
data: StorableDashboardData{"widgets": makeTestWidgets("a")},
expectDeletable: true,
},
{
subtestName: "user dashboard with unreadable data",
source: SourceUser,
data: StorableDashboardData{"metadata": "not-an-object"},
expectDeletable: true,
},
{
subtestName: "locked user dashboard",
locked: true,
source: SourceUser,
data: StorableDashboardData{"widgets": makeTestWidgets("a")},
expectDeletable: false,
},
{
subtestName: "system dashboard",
source: SourceSystem,
data: StorableDashboardData{"widgets": makeTestWidgets("a")},
expectDeletable: false,
},
{
subtestName: "integration dashboard",
source: SourceIntegration,
data: StorableDashboardData{"widgets": makeTestWidgets("a")},
expectDeletable: false,
},
}
for _, tc := range testCases {
t.Run(tc.subtestName, func(t *testing.T) {
storable := StorableDashboard{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
OrgID: valuer.GenerateUUID(),
Locked: tc.locked,
Source: tc.source,
Data: tc.data,
}
assert.Equal(t, tc.expectDeletable, storable.ErrIfNotDeletable() == nil)
})
}
}

View File

@@ -129,6 +129,16 @@ func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) erro
return nil
}
func (d *DashboardV2) ErrIfNotDeletable() error {
if d.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot delete a locked dashboard, please unlock the dashboard to delete")
}
if !d.Source.isUserDeletable() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be deleted", d.Source)
}
return nil
}
func (d *DashboardV2) ErrIfNotClonable() error {
if !d.Source.isClonable() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be cloned", d.Source)

View File

@@ -1,78 +0,0 @@
package telemetrytypes
import "strings"
// LogicalField is one queryable field. Its Name is the spelling that the
// request used. Its Members are the physical keys that store the field.
// LogicalField is the output type of name resolution: resolution changes a
// referenced name into logical fields, and compilers make SQL from them.
//
// A []*LogicalField shows ambiguity. Ambiguity means that possibly different
// fields have the same name. Each logical field in the slice gets its own
// condition. The operator tells the compiler how to connect the conditions.
//
// One LogicalField with more than one member shows a semantic-convention
// family. A family is one field that has more than one spelling. The members
// are in current-first order. The compiler merges the members into one
// expression, and the current name wins.
//
// Members always has one entry or more. A field that is not a family has
// exactly one member. The members point to the metadata map entries. Do not
// change the members.
type LogicalField struct {
// Name is the spelling that the request used. Aliases, series labels,
// and warnings use this spelling. Because of this, the response shows
// the same spelling as the request.
Name string
// Signal, FieldContext, and FieldDataType are the identity that all
// members share. Members with a different signal, field context, or
// data type are parts of different logical fields.
Signal Signal
FieldContext FieldContext
FieldDataType FieldDataType
// Members are the physical keys that store this field, in current-first
// order. Each member has its own physical data (Materialized,
// Evolutions, JSONPlan, ...). A per-member accessor does not need data
// from the other members.
Members []*TelemetryFieldKey
}
// SingleLogicalField makes a logical field that has one physical key.
func SingleLogicalField(name string, key *TelemetryFieldKey) *LogicalField {
return &LogicalField{
Name: name,
Signal: key.Signal,
FieldContext: key.FieldContext,
FieldDataType: key.FieldDataType,
Members: []*TelemetryFieldKey{key},
}
}
// Single returns the only member of a single-member field. A decision that
// uses only the shared identity can also use Single on a family. This is
// safe because all members have the same signal, context, and data type.
func (l *LogicalField) Single() *TelemetryFieldKey {
return l.Members[0]
}
// IsFamily returns true when the field has more than one physical member.
func (l *LogicalField) IsFamily() bool {
return len(l.Members) > 1
}
// String implements fmt.Stringer. A single-member field prints as its
// member. Because of this, a message made from the field and a message made
// from the key are the same. A family prints its shared identity and its
// member spellings.
func (l *LogicalField) String() string {
if len(l.Members) == 1 {
return l.Members[0].String()
}
names := make([]string, 0, len(l.Members))
for _, member := range l.Members {
names = append(names, member.Name)
}
return l.Name + "(" + l.FieldContext.StringValue() + ", " + l.FieldDataType.StringValue() + ", members: " + strings.Join(names, ", ") + ")"
}

View File

@@ -1,50 +0,0 @@
package telemetrytypes
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSingleLogicalFieldSharesIdentityAndAliasesKey(t *testing.T) {
key := &TelemetryFieldKey{
Name: "service.name",
Signal: SignalTraces,
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
}
logical := SingleLogicalField("resource.service.name", key)
assert.Equal(t, "resource.service.name", logical.Name, "the identity is the spelling that the request used, not the stored spelling")
assert.Equal(t, key.Signal, logical.Signal)
assert.Equal(t, key.FieldContext, logical.FieldContext)
assert.Equal(t, key.FieldDataType, logical.FieldDataType)
assert.False(t, logical.IsFamily())
assert.Same(t, key, logical.Single(), "the member points to the key; there is no copy")
}
func TestStringDelegatesForSingleMember(t *testing.T) {
key := &TelemetryFieldKey{
Name: "service.name",
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
}
assert.Equal(t, key.String(), SingleLogicalField(key.Name, key).String(),
"a message made from a single-member field must be the same as a message made from the key")
}
func TestStringListsFamilyMembers(t *testing.T) {
logical := &LogicalField{
Name: "deployment.environment.name",
Signal: SignalTraces,
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
Members: []*TelemetryFieldKey{
{Name: "deployment.environment.name"},
{Name: "deployment.environment"},
},
}
assert.True(t, logical.IsFamily())
assert.Equal(t, "deployment.environment.name(resource, string, members: deployment.environment.name, deployment.environment)", logical.String())
}

View File

@@ -26,14 +26,14 @@ class ProviderAccountSpec:
provider: str
# params for the account created by default.
initial_params: dict
# params for the config an update (PUT) test sends.
updated_params: dict
# params -> the provider-keyed `config` block for a POST/PUT body.
build_config: Callable[[dict], dict]
# params -> the full config block the API is expected to return under
# config[provider] on GET/list. This may differ from what build_config sends:
# e.g. AWS accepts deploymentRegion on POST but the API does not echo it back.
expected_config: Callable[[dict], dict]
# only the suites that exercise updates need to supply it.
updated_params: dict = field(default_factory=dict)
# id shown in parametrized test names; defaults to the provider slug.
id: str = field(default="")
@@ -42,29 +42,6 @@ class ProviderAccountSpec:
object.__setattr__(self, "id", self.provider)
# Per-provider service shape.
@dataclass(frozen=True)
class ProviderServiceSpec:
provider: str
service_id: str
# GCP ships every service with supportedSignals.logs false, so a logs block
# is neither required on write nor persisted.
supports_logs: bool
account_config: dict
# id shown in parametrized test names; defaults to the provider slug.
id: str = field(default="")
def __post_init__(self) -> None:
if not self.id:
object.__setattr__(self, "id", self.provider)
def build_service_config(self, metrics_enabled: bool, logs_enabled: bool | None = None) -> dict:
config: dict = {"metrics": {"enabled": metrics_enabled}}
if self.supports_logs:
config["logs"] = {"enabled": metrics_enabled if logs_enabled is None else logs_enabled}
return {self.provider: config}
@pytest.fixture(scope="function")
def deprecated_create_cloud_integration_account(
request: pytest.FixtureRequest,

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +1,14 @@
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.cloudintegrations import ProviderAccountSpec
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
AWS_ACCOUNT_SPEC = ProviderAccountSpec(
provider="aws",
initial_params={"deployment_region": "us-east-1", "regions": ["us-east-1", "us-west-2"]},
build_config=lambda p: {"aws": {"deploymentRegion": p["deployment_region"], "regions": p["regions"]}},
expected_config=lambda p: {"regions": p["regions"]},
)
GCP_ACCOUNT_SPEC = ProviderAccountSpec(
provider="gcp",
initial_params={
"deployment_project_id": "signoz-test-project",
"deployment_region": "us-central1",
"project_ids": ["signoz-test-project"],
},
build_config=lambda p: {
"gcp": {
"deploymentProjectId": p["deployment_project_id"],
"deploymentRegion": p["deployment_region"],
"projectIds": p["project_ids"],
}
},
expected_config=lambda p: {
"deploymentProjectId": p["deployment_project_id"],
"deploymentRegion": p["deployment_region"],
"projectIds": p["project_ids"],
},
)
PROVIDER_ACCOUNT_SPECS = [AWS_ACCOUNT_SPEC, GCP_ACCOUNT_SPEC]
provider_spec = pytest.mark.parametrize(
"spec",
PROVIDER_ACCOUNT_SPECS,
ids=[s.id for s in PROVIDER_ACCOUNT_SPECS],
)
def test_apply_license(
signoz: types.SigNoz,
@@ -58,19 +20,19 @@ def test_apply_license(
add_license(signoz, make_http_mocks, get_token)
@provider_spec
def test_create_account(
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
cloud_provider = "aws"
data = create_cloud_integration_account(
admin_token,
spec.provider,
config=spec.build_config(spec.initial_params),
cloud_provider,
deployment_region="us-east-1",
regions=["us-east-1", "us-west-2"],
)
assert "id" in data, "Response data should contain 'id' field"
@@ -78,17 +40,12 @@ def test_create_account(
assert "connectionArtifact" in data, "Response data should contain 'connectionArtifact' field"
artifact = data["connectionArtifact"]
assert "aws" in artifact, "connectionArtifact should contain 'aws' field"
assert "connectionUrl" in artifact["aws"], "connectionArtifact.aws should contain 'connectionUrl'"
if spec.provider == "aws":
assert "aws" in artifact, "connectionArtifact should contain 'aws' field"
assert "connectionUrl" in artifact["aws"], "connectionArtifact.aws should contain 'connectionUrl'"
connection_url = artifact["aws"]["connectionUrl"]
assert "console.aws.amazon.com/cloudformation" in connection_url, "connectionUrl should be an AWS CloudFormation URL"
assert f"region={spec.initial_params['deployment_region']}" in connection_url, "connectionUrl should contain the deployment region"
else:
# GCP is a manual flow: no one-click install artifact.
assert artifact.get("gcp") is None, f"GCP should not return a connection artifact, got: {artifact}"
connection_url = artifact["aws"]["connectionUrl"]
assert "console.aws.amazon.com/cloudformation" in connection_url, "connectionUrl should be an AWS CloudFormation URL"
assert "region=us-east-1" in connection_url, "connectionUrl should contain the deployment region"
def test_create_account_unsupported_provider(
@@ -119,36 +76,3 @@ def test_create_account_unsupported_provider(
response_data = response.json()
assert "error" in response_data, "Response should contain 'error' field"
def test_create_gcp_account_without_project_ids(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""GCP account config requires at least one project ID to monitor."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/cloud_integrations/gcp/accounts"),
headers={"Authorization": f"Bearer {admin_token}"},
json={
"config": {
"gcp": {
"deploymentProjectId": "signoz-test-project",
"deploymentRegion": "us-central1",
"projectIds": [],
}
},
"credentials": {
"sigNozApiURL": "https://test.signoz.cloud",
"sigNozApiKey": "test-key",
"ingestionUrl": "https://ingest.test.signoz.cloud",
"ingestionKey": "test-ingestion-key",
},
},
timeout=10,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, f"Expected 400 for empty projectIds, got {response.status_code}: {response.text}"
assert "error" in response.json(), "Response should contain 'error' field"

View File

@@ -2,53 +2,14 @@ import uuid
from collections.abc import Callable
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.cloudintegrations import (
ProviderAccountSpec,
simulate_agent_checkin,
)
from fixtures.cloudintegrations import simulate_agent_checkin
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
AWS_ACCOUNT_SPEC = ProviderAccountSpec(
provider="aws",
initial_params={"deployment_region": "us-east-1", "regions": ["us-east-1"]},
build_config=lambda p: {"aws": {"deploymentRegion": p["deployment_region"], "regions": p["regions"]}},
expected_config=lambda p: {"regions": p["regions"]},
)
GCP_ACCOUNT_SPEC = ProviderAccountSpec(
provider="gcp",
initial_params={
"deployment_project_id": "signoz-test-project",
"deployment_region": "us-central1",
"project_ids": ["signoz-test-project"],
},
build_config=lambda p: {
"gcp": {
"deploymentProjectId": p["deployment_project_id"],
"deploymentRegion": p["deployment_region"],
"projectIds": p["project_ids"],
}
},
expected_config=lambda p: {
"deploymentProjectId": p["deployment_project_id"],
"deploymentRegion": p["deployment_region"],
"projectIds": p["project_ids"],
},
)
PROVIDER_ACCOUNT_SPECS = [AWS_ACCOUNT_SPEC, GCP_ACCOUNT_SPEC]
provider_spec = pytest.mark.parametrize(
"spec",
PROVIDER_ACCOUNT_SPECS,
ids=[s.id for s in PROVIDER_ACCOUNT_SPECS],
)
CLOUD_PROVIDER = "aws"
def test_apply_license(
@@ -61,28 +22,22 @@ def test_apply_license(
add_license(signoz, make_http_mocks, get_token)
@provider_spec
def test_agent_check_in(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(
admin_token,
spec.provider,
config=spec.build_config(spec.initial_params),
)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER, regions=["us-east-1"])
account_id = account["id"]
provider_account_id = str(uuid.uuid4())
response = simulate_agent_checkin(
signoz,
admin_token,
spec.provider,
CLOUD_PROVIDER,
account_id,
provider_account_id,
data={"version": "v0.0.8"},
@@ -92,63 +47,57 @@ def test_agent_check_in(
data = response.json()["data"]
# New camelCase fields
assert data["cloudIntegrationId"] == account_id, "cloudIntegrationId should match"
assert data["providerAccountId"] == provider_account_id, "providerAccountId should match"
assert "integrationConfig" in data, "Response should contain 'integrationConfig'"
assert data["removedAt"] is None, "removedAt should be null for a live account"
if spec.provider == "aws":
# Backward compat for agents deployed before the camelCase response; AWS only.
assert data["account_id"] == account_id, "account_id (compat) should match"
assert data["cloud_account_id"] == provider_account_id, "cloud_account_id (compat) should match"
assert "integration_config" in data, "Response should contain 'integration_config' (compat)"
assert "removed_at" in data, "Response should contain 'removed_at' (compat)"
# Backward-compat snake_case fields
assert data["account_id"] == account_id, "account_id (compat) should match"
assert data["cloud_account_id"] == provider_account_id, "cloud_account_id (compat) should match"
assert "integration_config" in data, "Response should contain 'integration_config' (compat)"
assert "removed_at" in data, "Response should contain 'removed_at' (compat)"
integration_config = data["integrationConfig"]
assert "aws" in integration_config, "integrationConfig should contain 'aws' block"
assert integration_config["aws"]["enabledRegions"] == spec.initial_params["regions"], "enabledRegions should match account config"
else:
# GCP is a manual flow: the agent carries its own configuration.
assert data["integrationConfig"].get("gcp") is None, f"GCP should not return an integration config, got: {data['integrationConfig']}"
# integrationConfig should reflect the configured regions
integration_config = data["integrationConfig"]
assert "aws" in integration_config, "integrationConfig should contain 'aws' block"
assert integration_config["aws"]["enabledRegions"] == ["us-east-1"], "enabledRegions should match account config"
@provider_spec
def test_agent_check_in_account_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderAccountSpec,
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
fake_id = str(uuid.uuid4())
response = simulate_agent_checkin(signoz, admin_token, spec.provider, fake_id, str(uuid.uuid4()))
response = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, fake_id, str(uuid.uuid4()))
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}: {response.text}"
@provider_spec
def test_duplicate_cloud_account_checkins(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
"""Test that two different accounts cannot check in with the same providerAccountId."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account1 = create_cloud_integration_account(admin_token, spec.provider, config=spec.build_config(spec.initial_params))
account2 = create_cloud_integration_account(admin_token, spec.provider, config=spec.build_config(spec.initial_params))
account1 = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account2 = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
assert account1["id"] != account2["id"], "Two accounts should have different IDs"
same_provider_account_id = str(uuid.uuid4())
# First check-in: account1 claims the provider account ID
response = simulate_agent_checkin(signoz, admin_token, spec.provider, account1["id"], same_provider_account_id)
response = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account1["id"], same_provider_account_id)
assert response.status_code == HTTPStatus.OK, f"Expected 200 for first check-in, got {response.status_code}: {response.text}"
# Second check-in: account2 tries to claim the same provider account ID → 409
response = simulate_agent_checkin(signoz, admin_token, spec.provider, account2["id"], same_provider_account_id)
response = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account2["id"], same_provider_account_id)
assert response.status_code == HTTPStatus.CONFLICT, f"Expected 409 for duplicate providerAccountId, got {response.status_code}: {response.text}"

View File

@@ -2,47 +2,18 @@ import uuid
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from sqlalchemy import bindparam, sql
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.cloudintegrations import (
ProviderServiceSpec,
simulate_agent_checkin,
)
from fixtures.cloudintegrations import simulate_agent_checkin
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
AWS_SERVICE_SPEC = ProviderServiceSpec(
provider="aws",
service_id="rds",
supports_logs=True,
account_config={"aws": {"deploymentRegion": "us-east-1", "regions": ["us-east-1"]}},
)
GCP_SERVICE_SPEC = ProviderServiceSpec(
provider="gcp",
service_id="cloudsql_postgres",
supports_logs=False,
account_config={
"gcp": {
"deploymentProjectId": "signoz-test-project",
"deploymentRegion": "us-central1",
"projectIds": ["signoz-test-project"],
}
},
)
PROVIDER_SERVICE_SPECS = [AWS_SERVICE_SPEC, GCP_SERVICE_SPEC]
provider_spec = pytest.mark.parametrize(
"spec",
PROVIDER_SERVICE_SPECS,
ids=[s.id for s in PROVIDER_SERVICE_SPECS],
)
CLOUD_PROVIDER = "aws"
SERVICE_ID = "rds"
def test_apply_license(
@@ -55,18 +26,16 @@ def test_apply_license(
add_license(signoz, make_http_mocks, get_token)
@provider_spec
def test_list_services_without_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderServiceSpec,
) -> None:
"""List the cloud provider's supported services"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/services"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -84,37 +53,35 @@ def test_list_services_without_account(
assert "icon" in service, "Service should have 'icon' field"
assert "enabled" in service, "Service should have 'enabled' field"
listed_ids = {s["id"] for s in data["services"]}
assert spec.service_id in listed_ids, f"'{spec.service_id}' should be listed for {spec.provider}"
EC2_SERVICE_ID = "ec2"
@provider_spec
def test_list_account_services(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""ListAccountServicesMetadata reflects enabled state per service."""
"""ListAccountServicesMetadata reflects enabled state after enabling a service."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
put_response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{EC2_SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(True)},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}},
timeout=10,
)
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Enable {spec.service_id} failed: {put_response.status_code}: {put_response.text}"
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Enable ec2 failed: {put_response.status_code}: {put_response.text}"
list_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -125,28 +92,21 @@ def test_list_account_services(
assert isinstance(data["services"], list), "services should be a list"
assert len(data["services"]) > 0, "services list should be non-empty"
enabled_service = next((s for s in data["services"] if s["id"] == spec.service_id), None)
assert enabled_service is not None, f"Service '{spec.service_id}' not found in services list"
assert enabled_service["enabled"] is True, f"Service should be enabled, got: {enabled_service['enabled']}"
# The listing must report state per service, not blanket-enable or echo the write.
untouched_service = next((s for s in data["services"] if s["id"] != spec.service_id), None)
assert untouched_service is not None, "Expected more than one service in the listing"
assert untouched_service["enabled"] is False, f"Service '{untouched_service['id']}' was never enabled, got: {untouched_service['enabled']}"
ec2_service = next((s for s in data["services"] if s["id"] == EC2_SERVICE_ID), None)
assert ec2_service is not None, f"EC2 service '{EC2_SERVICE_ID}' not found in services list"
assert ec2_service["enabled"] is True, f"EC2 service should be enabled, got: {ec2_service['enabled']}"
@provider_spec
def test_get_service_details_without_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderServiceSpec,
) -> None:
"""Get full service definition without specifying an account."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -154,36 +114,31 @@ def test_get_service_details_without_account(
assert response.status_code == HTTPStatus.OK, f"Expected 200, got {response.status_code}"
data = response.json()["data"]
assert data["id"] == spec.service_id, f"id should be '{spec.service_id}'"
assert data["id"] == SERVICE_ID, f"id should be '{SERVICE_ID}'"
assert "title" in data, "Service should have 'title'"
assert "overview" in data, "Service should have 'overview' (markdown)"
assert "assets" in data, "Service should have 'assets'"
assert isinstance(data["assets"]["dashboards"], list), "assets.dashboards should be a list"
assert data["cloudIntegrationService"] is None, "cloudIntegrationService should be null without account context"
assert data["supportedSignals"]["metrics"] is True, "metrics should be a supported signal"
assert data["supportedSignals"]["logs"] is spec.supports_logs, f"logs support should be {spec.supports_logs} for {spec.provider}"
@provider_spec
def test_get_account_service(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Get service for a specific account — all disabled by default."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -191,22 +146,20 @@ def test_get_account_service(
assert response.status_code == HTTPStatus.OK, f"Expected 200, got {response.status_code}"
data = response.json()["data"]
assert data["id"] == spec.service_id, f"id should be '{spec.service_id}'"
assert data["id"] == SERVICE_ID, f"id should be '{SERVICE_ID}'"
assert data["cloudIntegrationService"] is None, "cloudIntegrationService should be null before any config is set"
@provider_spec
def test_get_service_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderServiceSpec,
) -> None:
"""Get a non-existent service ID returns 400 (invalid service ID is a bad request)."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/services/non-existent-service"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/non-existent-service"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -214,34 +167,32 @@ def test_get_service_not_found(
assert response.status_code == HTTPStatus.BAD_REQUEST, f"Expected 400, got {response.status_code}"
@provider_spec
def test_update_service_config(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Enable a service and verify the config is persisted via GET."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
put_response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(True)},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}},
timeout=10,
)
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {put_response.status_code}: {put_response.text}"
get_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -250,39 +201,33 @@ def test_update_service_config(
data = get_response.json()["data"]
svc = data["cloudIntegrationService"]
assert svc is not None, "cloudIntegrationService should be non-null after UpdateService"
assert svc["config"][spec.provider]["metrics"]["enabled"] is True, "metrics should be enabled"
assert svc["config"]["aws"]["metrics"]["enabled"] is True, "metrics should be enabled"
assert svc["config"]["aws"]["logs"]["enabled"] is True, "logs should be enabled"
assert svc["cloudIntegrationId"] == account_id, "cloudIntegrationId should match the account"
if spec.supports_logs:
assert svc["config"][spec.provider]["logs"]["enabled"] is True, "logs should be enabled"
else:
assert svc["config"][spec.provider].get("logs") is None, f"logs should not be stored for {spec.provider}, got: {svc['config'][spec.provider]}"
@provider_spec
def test_update_service_config_disable(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Enable then disable a service — config change is persisted."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
endpoint = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}")
endpoint = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}")
# Enable
r = requests.put(
endpoint,
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(True)},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}},
timeout=10,
)
assert r.status_code == HTTPStatus.NO_CONTENT, f"Enable failed: {r.status_code}: {r.text}"
@@ -291,13 +236,13 @@ def test_update_service_config_disable(
r = requests.put(
endpoint,
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(False)},
json={"config": {"aws": {"metrics": {"enabled": False}, "logs": {"enabled": False}}}},
timeout=10,
)
assert r.status_code == HTTPStatus.NO_CONTENT, f"Disable failed: {r.status_code}: {r.text}"
get_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -305,57 +250,28 @@ def test_update_service_config_disable(
assert get_response.status_code == HTTPStatus.OK
svc = get_response.json()["data"]["cloudIntegrationService"]
assert svc is not None, "cloudIntegrationService should still be present after disable"
assert svc["config"][spec.provider]["metrics"]["enabled"] is False, "metrics should be disabled"
if spec.supports_logs:
assert svc["config"][spec.provider]["logs"]["enabled"] is False, "logs should be disabled"
assert svc["config"]["aws"]["metrics"]["enabled"] is False, "metrics should be disabled"
assert svc["config"]["aws"]["logs"]["enabled"] is False, "logs should be disabled"
@provider_spec
def test_update_service_account_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderServiceSpec,
) -> None:
"""PUT with a non-existent account UUID returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{uuid.uuid4()}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{uuid.uuid4()}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(True)},
json={"config": {"aws": {"metrics": {"enabled": True}}}},
timeout=10,
)
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
def test_update_gcp_service_without_metrics_config(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""GCP services support metrics only, so a config omitting metrics is rejected."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, "gcp", config=GCP_SERVICE_SPEC.account_config)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, "gcp", account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/gcp/accounts/{account_id}/services/{GCP_SERVICE_SPEC.service_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"gcp": {"logs": {"enabled": True}}}},
timeout=10,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, f"Expected 400 when metrics config is missing, got {response.status_code}: {response.text}"
def test_list_services_unsupported_provider(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
@@ -373,32 +289,30 @@ def test_list_services_unsupported_provider(
assert response.status_code == HTTPStatus.BAD_REQUEST, f"Expected 400, got {response.status_code}"
@provider_spec
def test_list_services_account_removed(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""List services for a deleted account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204 on delete, got {delete_response.status_code}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -406,32 +320,30 @@ def test_list_services_account_removed(
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
@provider_spec
def test_get_service_details_account_removed(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Get service details for a deleted account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204 on delete, got {delete_response.status_code}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -439,68 +351,64 @@ def test_get_service_details_account_removed(
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
@provider_spec
def test_update_service_account_removed(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""PUT service config for a deleted account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204 on delete, got {delete_response.status_code}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(True)},
json={"config": {"aws": {"metrics": {"enabled": True}}}},
timeout=10,
)
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
@provider_spec
def test_enable_metrics_provisions_dashboards(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Enabling metrics provisions dashboards visible in GetService and present in the DB."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
put_response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(True, logs_enabled=False)},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": False}}}},
timeout=10,
)
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {put_response.status_code}: {put_response.text}"
# Assertion 1: GetService returns provisioned dashboard UUIDs
get_svc_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -509,7 +417,7 @@ def test_enable_metrics_provisions_dashboards(
data = get_svc_response.json()["data"]
svc = data["cloudIntegrationService"]
assert svc is not None, "cloudIntegrationService should be non-null after enabling metrics"
assert svc["config"][spec.provider]["metrics"]["enabled"] is True
assert svc["config"]["aws"]["metrics"]["enabled"] is True
dashboards_in_service = data["assets"]["dashboards"]
assert isinstance(dashboards_in_service, list) and len(dashboards_in_service) > 0, "assets.dashboards should be non-empty after enabling metrics"
@@ -537,37 +445,35 @@ def test_enable_metrics_provisions_dashboards(
assert provisioned_ids == db_ids, f"Dashboards {provisioned_ids - db_ids} are missing from the DB"
@provider_spec
def test_disable_metrics_deprovisions_dashboards(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Disabling metrics removes provisioned dashboards from both GetService and the dashboards list."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
endpoint = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}")
endpoint = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}")
# Enable metrics to provision dashboards first
enable_response = requests.put(
endpoint,
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(True, logs_enabled=False)},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": False}}}},
timeout=10,
)
assert enable_response.status_code == HTTPStatus.NO_CONTENT, f"Enable failed: {enable_response.status_code}: {enable_response.text}"
# Capture the provisioned dashboard IDs before disabling
get_svc_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -579,14 +485,14 @@ def test_disable_metrics_deprovisions_dashboards(
disable_response = requests.put(
endpoint,
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(False)},
json={"config": {"aws": {"metrics": {"enabled": False}, "logs": {"enabled": False}}}},
timeout=10,
)
assert disable_response.status_code == HTTPStatus.NO_CONTENT, f"Disable failed: {disable_response.status_code}: {disable_response.text}"
# Assertion 1: GetService no longer returns UUID dashboard IDs
get_svc_after = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)

View File

@@ -1,14 +1,20 @@
import os
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import get_column_data_from_response, make_query_request
TESTDATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "testdata")
FILTER_EXPRESSIONS_FILE = os.path.join(TESTDATA_DIR, "filter_expressions_10000.txt")
@pytest.mark.parametrize(
"expression,expected_logs",
@@ -174,3 +180,101 @@ def test_not_filter_expression(
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
assert set(get_column_data_from_response(response.json(), "body")) == expected_logs
def test_filter_expressions_no_server_error(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
insert_logs,
get_token: Callable[[str, str], str],
) -> None:
"""
Reads every line from filter_expressions_10000.txt and fires it as a filter
expression against the logs query endpoint.
Expressions may be valid (200) or invalid (400) — both are acceptable.
A 500 means the server crashed on the input and is a test failure.
All failing expressions are collected before asserting so the full list is
visible in one run.
"""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
now = datetime.now(tz=UTC)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=5),
body="alpha-log",
resources={
"f1": "v10",
"f2": "v20",
"f3": "v30",
},
attributes={
"f4": 40,
"f5": 50,
"f6": 60,
},
),
Logs(
timestamp=now - timedelta(seconds=3),
body="beta-log",
resources={
"f4": "v41",
"f5": "v51",
"f6": "v61",
},
attributes={
"f1": 11,
"f2": 21,
"f3": 31,
},
),
]
)
def _make_raw_logs_query(
signoz: types.SigNoz,
token: str,
filter_expression: str,
) -> requests.Response:
"""Helper to query raw logs with a filter expression over the last 30 seconds."""
now = datetime.now(tz=UTC)
return make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"filter": {"expression": filter_expression},
"order": [
{"key": {"name": "timestamp"}, "direction": "desc"},
{"key": {"name": "id"}, "direction": "desc"},
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
failures: list[str] = []
with ThreadPoolExecutor(max_workers=40) as executor:
with open(FILTER_EXPRESSIONS_FILE, encoding="utf-8") as f:
futures = {executor.submit(_make_raw_logs_query, signoz, token, expr.rstrip("\n")): expr.rstrip("\n") for expr in f}
for future in as_completed(futures):
expr = futures[future]
if future.result().status_code == HTTPStatus.INTERNAL_SERVER_ERROR:
failures.append(expr)
assert len(failures) <= 0, f"{len(failures)} expression(s) caused HTTP 500:\n" + "\n".join(f" {expr!r}" for expr in failures)