mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-11 15:30:47 +01:00
Compare commits
1 Commits
issue-5892
...
feat/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7962bac4d |
@@ -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;
|
||||
|
||||
31
frontend/src/api/v1/factor_password/resetPassword.ts
Normal file
31
frontend/src/api/v1/factor_password/resetPassword.ts
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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={
|
||||
|
||||
47
frontend/src/components/CustomTimePicker/useInvalidFlash.ts
Normal file
47
frontend/src/components/CustomTimePicker/useInvalidFlash.ts
Normal 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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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')];
|
||||
|
||||
@@ -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"> </div>
|
||||
</div>
|
||||
|
||||
{isLogDetailsV2 && <LogHighlights log={log} />}
|
||||
|
||||
<div className="tabs-and-search">
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />}
|
||||
>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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: '' },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
9
frontend/src/types/api/user/resetPassword.ts
Normal file
9
frontend/src/types/api/user/resetPassword.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface Props {
|
||||
token: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PayloadProps {
|
||||
data: string;
|
||||
status: string;
|
||||
}
|
||||
78
frontend/src/utils/__tests__/timeUtils.test.ts
Normal file
78
frontend/src/utils/__tests__/timeUtils.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, ", ") + ")"
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
27
tests/fixtures/cloudintegrations.py
vendored
27
tests/fixtures/cloudintegrations.py
vendored
@@ -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,
|
||||
|
||||
10000
tests/integration/testdata/filter_expressions_10000.txt
vendored
Normal file
10000
tests/integration/testdata/filter_expressions_10000.txt
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user