mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-19 16:30:31 +01:00
Compare commits
6 Commits
fix/span-s
...
fix/downti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90a98ab698 | ||
|
|
82cca67063 | ||
|
|
5bd4cabbca | ||
|
|
f9e21cecd8 | ||
|
|
4b98b0bb27 | ||
|
|
99bdee8ee3 |
@@ -34,7 +34,7 @@ export default defineConfig({
|
||||
signal: true,
|
||||
useOperationIdAsQueryKey: false,
|
||||
},
|
||||
useDates: true,
|
||||
useDates: false,
|
||||
useNamedParameters: true,
|
||||
enumGenerationType: 'enum',
|
||||
mutator: {
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface AlertmanagertypesChannelDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -34,7 +34,7 @@ export interface AlertmanagertypesChannelDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ModelLabelSetDTO {
|
||||
@@ -62,7 +62,7 @@ export interface AlertmanagertypesDeprecatedGettableAlertDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
endsAt?: Date;
|
||||
endsAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -80,7 +80,7 @@ export interface AlertmanagertypesDeprecatedGettableAlertDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startsAt?: Date;
|
||||
startsAt?: string;
|
||||
status?: TypesAlertStatusDTO;
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export interface AlertmanagertypesGettableRoutePolicyDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: Date;
|
||||
createdAt: string;
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
@@ -127,7 +127,7 @@ export interface AlertmanagertypesGettableRoutePolicyDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: Date;
|
||||
updatedAt: string;
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
@@ -1834,7 +1834,7 @@ export interface AuthtypesGettableAuthDomainDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -1851,7 +1851,7 @@ export interface AuthtypesGettableAuthDomainDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableTokenDTO {
|
||||
@@ -2009,7 +2009,7 @@ export interface AuthtypesRoleDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2034,7 +2034,7 @@ export interface AuthtypesRoleDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesSessionContextDTO {
|
||||
@@ -2062,7 +2062,7 @@ export interface AuthtypesUserRoleDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: Date;
|
||||
createdAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2076,7 +2076,7 @@ export interface AuthtypesUserRoleDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: Date;
|
||||
updatedAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2088,7 +2088,7 @@ export interface AuthtypesUserWithRolesDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2117,7 +2117,7 @@ export interface AuthtypesUserWithRolesDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
@@ -2284,7 +2284,7 @@ export interface CloudintegrationtypesAccountDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2305,12 +2305,12 @@ export interface CloudintegrationtypesAccountDTO {
|
||||
* @type string,null
|
||||
* @format date-time
|
||||
*/
|
||||
removedAt: Date | null;
|
||||
removedAt: string | null;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesStorableDashboardDataDTO {
|
||||
@@ -2441,7 +2441,7 @@ export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2451,7 +2451,7 @@ export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -2645,12 +2645,12 @@ export interface CloudintegrationtypesGettableAgentCheckInDTO {
|
||||
* @type string,null
|
||||
* @format date-time
|
||||
*/
|
||||
removed_at: Date | null;
|
||||
removed_at: string | null;
|
||||
/**
|
||||
* @type string,null
|
||||
* @format date-time
|
||||
*/
|
||||
removedAt: Date | null;
|
||||
removedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceMetadataDTO {
|
||||
@@ -2885,7 +2885,7 @@ export interface DashboardtypesDashboardDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2907,7 +2907,7 @@ export interface DashboardtypesDashboardDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3089,7 +3089,7 @@ export interface GatewaytypesLimitDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
created_at?: Date;
|
||||
created_at?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3111,7 +3111,7 @@ export interface GatewaytypesLimitDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updated_at?: Date;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface GatewaytypesIngestionKeyDTO {
|
||||
@@ -3119,12 +3119,12 @@ export interface GatewaytypesIngestionKeyDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
created_at?: Date;
|
||||
created_at?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
expires_at?: Date;
|
||||
expires_at?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3145,7 +3145,7 @@ export interface GatewaytypesIngestionKeyDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updated_at?: Date;
|
||||
updated_at?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3169,7 +3169,7 @@ export interface GatewaytypesPostableIngestionKeyDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
expires_at?: Date;
|
||||
expires_at?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4439,7 +4439,7 @@ export interface LlmpricingruletypesLLMPricingRuleDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4478,13 +4478,13 @@ export interface LlmpricingruletypesLLMPricingRuleDTO {
|
||||
* @type string,null
|
||||
* @format date-time
|
||||
*/
|
||||
syncedAt?: Date | null;
|
||||
syncedAt?: string | null;
|
||||
unit: LlmpricingruletypesLLMPricingRuleUnitDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -5710,7 +5710,7 @@ export interface Querybuildertypesv5RawRowDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
timestamp?: Date;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5RawDataDTO {
|
||||
@@ -6179,7 +6179,7 @@ export interface RuletypesRecurrenceDTO {
|
||||
* @type string,null
|
||||
* @format date-time
|
||||
*/
|
||||
endTime?: Date | null;
|
||||
endTime?: string | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
@@ -6189,7 +6189,7 @@ export interface RuletypesRecurrenceDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime: Date;
|
||||
startTime: string;
|
||||
}
|
||||
|
||||
export interface RuletypesScheduleDTO {
|
||||
@@ -6197,13 +6197,13 @@ export interface RuletypesScheduleDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
endTime?: Date;
|
||||
endTime?: string;
|
||||
recurrence?: RuletypesRecurrenceDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime?: Date;
|
||||
startTime?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6219,7 +6219,7 @@ export interface RuletypesPlannedMaintenanceDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6243,7 +6243,7 @@ export interface RuletypesPlannedMaintenanceDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6406,7 +6406,7 @@ export interface RuletypesRuleDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6455,7 +6455,7 @@ export interface RuletypesRuleDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6474,7 +6474,7 @@ export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
@@ -6488,7 +6488,7 @@ export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
lastObservedAt: Date;
|
||||
lastObservedAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6501,7 +6501,7 @@ export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO {
|
||||
@@ -6546,7 +6546,7 @@ export interface ServiceaccounttypesServiceAccountDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6571,7 +6571,7 @@ export interface ServiceaccounttypesServiceAccountDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesServiceAccountRoleDTO {
|
||||
@@ -6579,7 +6579,7 @@ export interface ServiceaccounttypesServiceAccountRoleDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6597,7 +6597,7 @@ export interface ServiceaccounttypesServiceAccountRoleDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesServiceAccountWithRolesDTO {
|
||||
@@ -6605,7 +6605,7 @@ export interface ServiceaccounttypesServiceAccountWithRolesDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6634,7 +6634,7 @@ export interface ServiceaccounttypesServiceAccountWithRolesDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
|
||||
@@ -6676,7 +6676,7 @@ export interface SpantypesSpanMapperGroupDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6701,7 +6701,7 @@ export interface SpantypesSpanMapperGroupDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6770,7 +6770,7 @@ export interface SpantypesSpanMapperDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6796,7 +6796,7 @@ export interface SpantypesSpanMapperDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -7163,7 +7163,7 @@ export interface TypesDeprecatedUserDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -7196,7 +7196,7 @@ export interface TypesDeprecatedUserDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TypesIdentifiableDTO {
|
||||
@@ -7211,7 +7211,7 @@ export interface TypesInviteDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -7244,7 +7244,7 @@ export interface TypesInviteDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TypesOrganizationDTO {
|
||||
@@ -7256,7 +7256,7 @@ export interface TypesOrganizationDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -7278,7 +7278,7 @@ export interface TypesOrganizationDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TypesPostableInviteDTO {
|
||||
@@ -7345,7 +7345,7 @@ export interface TypesResetPasswordTokenDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
expiresAt?: Date;
|
||||
expiresAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -7372,7 +7372,7 @@ export interface TypesUserDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -7401,7 +7401,7 @@ export interface TypesUserDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ZeustypesHostDTO {
|
||||
|
||||
@@ -59,7 +59,7 @@ function getDeleteTooltip(
|
||||
|
||||
function getInviteButtonLabel(
|
||||
isLoading: boolean,
|
||||
existingToken: { expiresAt?: Date } | undefined,
|
||||
existingToken: { expiresAt?: string } | undefined,
|
||||
isExpired: boolean,
|
||||
notFound: boolean,
|
||||
): string {
|
||||
|
||||
@@ -18,21 +18,22 @@ import { Button } from '@signozhq/ui/button';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { GripVertical } from '@signozhq/icons';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './FieldsSettings.module.scss';
|
||||
import styles from './FieldsSelector.module.scss';
|
||||
|
||||
function SortableField({
|
||||
field,
|
||||
onRemove,
|
||||
allowDrag,
|
||||
}: {
|
||||
field: BaseAutocompleteData;
|
||||
onRemove: (field: BaseAutocompleteData) => void;
|
||||
field: TelemetryFieldKey;
|
||||
onRemove: (field: TelemetryFieldKey) => void;
|
||||
allowDrag: boolean;
|
||||
}): JSX.Element {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: field.key });
|
||||
useSortable({ id: field.name });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -50,7 +51,7 @@ function SortableField({
|
||||
>
|
||||
<div {...attributes} {...listeners} className={styles.dragHandle}>
|
||||
{allowDrag && <GripVertical size={14} />}
|
||||
<span className={styles.fieldKey}>{field.key}</span>
|
||||
<span className={styles.fieldKey}>{field.name}</span>
|
||||
</div>
|
||||
<Button
|
||||
className={cx(styles.removeBtn, 'periscope-btn')}
|
||||
@@ -67,41 +68,52 @@ function SortableField({
|
||||
|
||||
interface AddedFieldsProps {
|
||||
inputValue: string;
|
||||
fields: BaseAutocompleteData[];
|
||||
onFieldsChange: (fields: BaseAutocompleteData[]) => void;
|
||||
fields: TelemetryFieldKey[];
|
||||
onFieldsChange: (fields: TelemetryFieldKey[]) => void;
|
||||
maxFields?: number;
|
||||
}
|
||||
|
||||
function AddedFields({
|
||||
inputValue,
|
||||
fields,
|
||||
onFieldsChange,
|
||||
maxFields,
|
||||
}: AddedFieldsProps): JSX.Element {
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent): void => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = fields.findIndex((f) => f.key === active.id);
|
||||
const newIndex = fields.findIndex((f) => f.key === over.id);
|
||||
const oldIndex = fields.findIndex((f) => f.name === active.id);
|
||||
const newIndex = fields.findIndex((f) => f.name === over.id);
|
||||
onFieldsChange(arrayMove(fields, oldIndex, newIndex));
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFields = useMemo(
|
||||
() =>
|
||||
fields.filter((f) => f.key.toLowerCase().includes(inputValue.toLowerCase())),
|
||||
fields.filter((f) =>
|
||||
f.name.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
),
|
||||
[fields, inputValue],
|
||||
);
|
||||
|
||||
const handleRemove = (field: BaseAutocompleteData): void => {
|
||||
onFieldsChange(fields.filter((f) => f.key !== field.key));
|
||||
const handleRemove = (field: TelemetryFieldKey): void => {
|
||||
onFieldsChange(fields.filter((f) => f.name !== field.name));
|
||||
};
|
||||
|
||||
const allowDrag = inputValue.length === 0;
|
||||
|
||||
return (
|
||||
<div className={cx(styles.section, styles.sectionAdded)}>
|
||||
<div className={styles.sectionHeader}>ADDED FIELDS</div>
|
||||
<div className={styles.sectionHeader}>
|
||||
<span>ADDED FIELDS</span>
|
||||
{maxFields !== undefined && (
|
||||
<Typography.Text size="sm" weight="medium" color="muted">
|
||||
Max Allowed: {maxFields}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.addedList}>
|
||||
<OverlayScrollbar>
|
||||
<DndContext
|
||||
@@ -113,13 +125,13 @@ function AddedFields({
|
||||
<div className={styles.noValues}>No values found</div>
|
||||
) : (
|
||||
<SortableContext
|
||||
items={fields.map((f) => f.key)}
|
||||
items={fields.map((f) => f.name)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
disabled={!allowDrag}
|
||||
>
|
||||
{filteredFields.map((field) => (
|
||||
<SortableField
|
||||
key={field.key}
|
||||
key={field.name}
|
||||
field={field}
|
||||
onRemove={handleRemove}
|
||||
allowDrag={allowDrag}
|
||||
@@ -56,12 +56,14 @@
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
@@ -89,13 +91,6 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.limitHint {
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.fieldItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
176
frontend/src/components/FieldsSelector/FieldsSelector.tsx
Normal file
176
frontend/src/components/FieldsSelector/FieldsSelector.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import AddedFields from './AddedFields';
|
||||
import OtherFields from './OtherFields';
|
||||
|
||||
import styles from './FieldsSelector.module.scss';
|
||||
|
||||
const DEFAULT_PANEL_WIDTH = 350;
|
||||
const DEFAULT_PANEL_HEIGHT_OFFSET = 100;
|
||||
const DEFAULT_PANEL_RIGHT_INSET = 100;
|
||||
const DEFAULT_PANEL_TOP_INSET = 50;
|
||||
|
||||
interface FieldsSelectorProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
fields: TelemetryFieldKey[];
|
||||
onFieldsChange: (fields: TelemetryFieldKey[]) => void;
|
||||
onClose: () => void;
|
||||
signal: DataSource;
|
||||
maxFields?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
defaultPosition?: { x: number; y: number };
|
||||
}
|
||||
|
||||
function FieldsSelector({
|
||||
isOpen,
|
||||
title,
|
||||
fields,
|
||||
onFieldsChange,
|
||||
onClose,
|
||||
signal,
|
||||
maxFields,
|
||||
width = DEFAULT_PANEL_WIDTH,
|
||||
height,
|
||||
defaultPosition,
|
||||
}: FieldsSelectorProps): JSX.Element | null {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedHeight =
|
||||
height ?? window.innerHeight - DEFAULT_PANEL_HEIGHT_OFFSET;
|
||||
const resolvedPosition = defaultPosition ?? {
|
||||
x: window.innerWidth - width - DEFAULT_PANEL_RIGHT_INSET,
|
||||
y: DEFAULT_PANEL_TOP_INSET,
|
||||
};
|
||||
const [draftFields, setDraftFields] = useState<TelemetryFieldKey[]>(fields);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [debouncedInputValue, setDebouncedInputValue] = useState('');
|
||||
|
||||
const debouncedUpdate = useDebouncedFn((value) => {
|
||||
setDebouncedInputValue(value as string);
|
||||
}, 400);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const value = e.target.value.trim().toLowerCase();
|
||||
setInputValue(value);
|
||||
debouncedUpdate(value);
|
||||
},
|
||||
[debouncedUpdate],
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(field: TelemetryFieldKey): void => {
|
||||
if (maxFields !== undefined && draftFields.length >= maxFields) {
|
||||
return;
|
||||
}
|
||||
if (draftFields.some((f) => f.name === field.name)) {
|
||||
return;
|
||||
}
|
||||
setDraftFields((prev) => [...prev, field]);
|
||||
},
|
||||
[draftFields, maxFields],
|
||||
);
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
onFieldsChange(draftFields);
|
||||
toast.success('Saved successfully', {
|
||||
position: 'top-right',
|
||||
});
|
||||
onClose();
|
||||
}, [draftFields, onFieldsChange, onClose]);
|
||||
|
||||
const handleDiscard = useCallback((): void => {
|
||||
setDraftFields(fields);
|
||||
}, [fields]);
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() =>
|
||||
!(
|
||||
draftFields.length === fields.length &&
|
||||
draftFields.every((f, i) => f.name === fields[i]?.name)
|
||||
),
|
||||
[draftFields, fields],
|
||||
);
|
||||
|
||||
const isAtLimit = maxFields !== undefined && draftFields.length >= maxFields;
|
||||
|
||||
return (
|
||||
<FloatingPanel
|
||||
isOpen
|
||||
width={width}
|
||||
height={resolvedHeight}
|
||||
defaultPosition={resolvedPosition}
|
||||
enableResizing={false}
|
||||
>
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
<TableColumnsSplit size={16} />
|
||||
{title}
|
||||
</div>
|
||||
<X className={styles.closeIcon} size={16} onClick={onClose} />
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<Input
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
placeholder="Search for a field..."
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<AddedFields
|
||||
inputValue={inputValue}
|
||||
fields={draftFields}
|
||||
onFieldsChange={setDraftFields}
|
||||
maxFields={maxFields}
|
||||
/>
|
||||
|
||||
<OtherFields
|
||||
signal={signal}
|
||||
debouncedInputValue={debouncedInputValue}
|
||||
addedFields={draftFields}
|
||||
onAdd={handleAdd}
|
||||
isAtLimit={isAtLimit}
|
||||
/>
|
||||
|
||||
{hasUnsavedChanges && (
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleDiscard}
|
||||
prefix={<X width={14} height={14} />}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSave}
|
||||
prefix={<Check width={14} height={14} />}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FloatingPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldsSelector;
|
||||
@@ -4,51 +4,58 @@ import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldDataType,
|
||||
SignalType,
|
||||
TelemetryFieldKey,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import styles from './FieldsSettings.module.scss';
|
||||
import styles from './FieldsSelector.module.scss';
|
||||
|
||||
interface OtherFieldsProps {
|
||||
dataSource: DataSource;
|
||||
signal: DataSource;
|
||||
debouncedInputValue: string;
|
||||
addedFields: BaseAutocompleteData[];
|
||||
onAdd: (field: BaseAutocompleteData) => void;
|
||||
addedFields: TelemetryFieldKey[];
|
||||
onAdd: (field: TelemetryFieldKey) => void;
|
||||
isAtLimit: boolean;
|
||||
}
|
||||
|
||||
function OtherFields({
|
||||
dataSource,
|
||||
signal,
|
||||
debouncedInputValue,
|
||||
addedFields,
|
||||
onAdd,
|
||||
isAtLimit,
|
||||
}: OtherFieldsProps): JSX.Element {
|
||||
// API call to get available attribute keys
|
||||
const { data, isFetching } = useGetAggregateKeys(
|
||||
const { data, isFetching } = useGetQueryKeySuggestions(
|
||||
{
|
||||
signal,
|
||||
searchText: debouncedInputValue,
|
||||
dataSource,
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: '',
|
||||
tagType: '',
|
||||
},
|
||||
{
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_OTHER_FILTERS,
|
||||
'preview-fields',
|
||||
REACT_QUERY_KEY.GET_FIELDS_SELECTOR_SUGGESTIONS,
|
||||
signal,
|
||||
debouncedInputValue,
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Filter out already-added fields, match on .key from API response objects
|
||||
const otherFields = useMemo(() => {
|
||||
const attributes = data?.payload?.attributeKeys || [];
|
||||
const addedKeys = new Set(addedFields.map((f) => f.key));
|
||||
return attributes.filter((attr) => !addedKeys.has(attr.key));
|
||||
const otherFields: TelemetryFieldKey[] = useMemo(() => {
|
||||
const suggestions = Object.values(data?.data.data.keys || {}).flat();
|
||||
const addedNames = new Set(addedFields.map((f) => f.name));
|
||||
return suggestions
|
||||
.filter((attr) => !addedNames.has(attr.name))
|
||||
.map((attr) => ({
|
||||
...attr,
|
||||
signal: attr.signal as SignalType,
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType as FieldDataType,
|
||||
}));
|
||||
}, [data, addedFields]);
|
||||
|
||||
if (isFetching) {
|
||||
@@ -76,10 +83,10 @@ function OtherFields({
|
||||
) : (
|
||||
otherFields.map((attr) => (
|
||||
<div
|
||||
key={attr.key}
|
||||
key={attr.name}
|
||||
className={cx(styles.fieldItem, styles.otherFieldItem)}
|
||||
>
|
||||
<span className={styles.fieldKey}>{attr.key}</span>
|
||||
<span className={styles.fieldKey}>{attr.name}</span>
|
||||
{!isAtLimit && (
|
||||
<Button
|
||||
className={cx(styles.addBtn, 'periscope-btn')}
|
||||
@@ -94,7 +101,6 @@ function OtherFields({
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isAtLimit && <div className={styles.limitHint}>Maximum 10 fields</div>}
|
||||
</>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
1
frontend/src/components/FieldsSelector/index.ts
Normal file
1
frontend/src/components/FieldsSelector/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './FieldsSelector';
|
||||
@@ -28,7 +28,7 @@ const mockKey: ServiceaccounttypesGettableFactorAPIKeyDTO = {
|
||||
id: 'key-1',
|
||||
name: 'Original Key Name',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null as unknown as Date,
|
||||
lastObservedAt: null as unknown as string,
|
||||
serviceAccountId: 'sa-1',
|
||||
};
|
||||
|
||||
|
||||
@@ -29,14 +29,14 @@ const keys: ServiceaccounttypesGettableFactorAPIKeyDTO[] = [
|
||||
id: 'key-1',
|
||||
name: 'Production Key',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null as unknown as Date,
|
||||
lastObservedAt: null as unknown as string,
|
||||
serviceAccountId: 'sa-1',
|
||||
},
|
||||
{
|
||||
id: 'key-2',
|
||||
name: 'Staging Key',
|
||||
expiresAt: 1924905600, // 2030-12-31
|
||||
lastObservedAt: new Date('2026-03-10T10:00:00Z'),
|
||||
lastObservedAt: '2026-03-10T10:00:00Z',
|
||||
serviceAccountId: 'sa-1',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -108,4 +108,7 @@ export const REACT_QUERY_KEY = {
|
||||
|
||||
// Dashboard Grid Card Query Keys
|
||||
DASHBOARD_GRID_CARD_QUERY_RANGE: 'DASHBOARD_GRID_CARD_QUERY_RANGE',
|
||||
|
||||
// Fields Selector Query Keys
|
||||
GET_FIELDS_SELECTOR_SUGGESTIONS: 'GET_FIELDS_SELECTOR_SUGGESTIONS',
|
||||
} as const;
|
||||
|
||||
@@ -186,77 +186,40 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.section-1 {
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
padding: 16px 18px 18px 14px;
|
||||
height: unset;
|
||||
padding: 8px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 12px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
border-top: none;
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-1,
|
||||
.section-2 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
padding: 16px 18px 18px 14px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.delete-dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
|
||||
.ant-typography {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
padding: 16px 18px 18px 14px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--bg-cherry-400) !important;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
}
|
||||
.delete-dashboard .ant-btn {
|
||||
color: var(--bg-cherry-400) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +211,12 @@
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
|
||||
.typography-variables {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.default-value-description {
|
||||
display: block;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Check, ExternalLink, Info, X } from '@signozhq/icons';
|
||||
import { Check, ExternalLink, SolidInfoCircle, X } from '@signozhq/icons';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
|
||||
import styles from './GeneralSettings.module.scss';
|
||||
@@ -201,7 +201,7 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<Info size={14} className={styles.crossPanelSyncInfoIcon} />
|
||||
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
|
||||
@@ -26,10 +26,13 @@
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.widget-header-title {
|
||||
max-width: 80%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.widget-header-actions {
|
||||
|
||||
@@ -438,9 +438,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
data: {
|
||||
name: values.name,
|
||||
tags: updatedTags,
|
||||
expires_at: new Date(
|
||||
dayjs(values.expires_at).endOf('day').toISOString(),
|
||||
),
|
||||
expires_at: dayjs(values.expires_at).endOf('day').toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -471,13 +469,11 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
const requestPayload = {
|
||||
name: values.name,
|
||||
tags: updatedTags,
|
||||
expires_at: new Date(dayjs(values.expires_at).endOf('day').toISOString()),
|
||||
expires_at: dayjs(values.expires_at).endOf('day').toISOString(),
|
||||
};
|
||||
|
||||
createIngestionKey(
|
||||
{
|
||||
data: requestPayload,
|
||||
},
|
||||
{ data: requestPayload },
|
||||
{
|
||||
onSuccess: (_data) => {
|
||||
notifications.success({
|
||||
|
||||
@@ -79,12 +79,12 @@ describe('MultiIngestionSettings Page', () => {
|
||||
keys: [
|
||||
{
|
||||
name: 'Key One',
|
||||
expires_at: new Date(TEST_EXPIRES_AT),
|
||||
expires_at: TEST_EXPIRES_AT,
|
||||
value: 'secret',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k1',
|
||||
created_at: new Date(TEST_CREATED_UPDATED),
|
||||
updated_at: new Date(TEST_CREATED_UPDATED),
|
||||
created_at: TEST_CREATED_UPDATED,
|
||||
updated_at: TEST_CREATED_UPDATED,
|
||||
tags: [],
|
||||
limits: [
|
||||
{
|
||||
@@ -160,12 +160,12 @@ describe('MultiIngestionSettings Page', () => {
|
||||
keys: [
|
||||
{
|
||||
name: 'Key Logs',
|
||||
expires_at: new Date(TEST_EXPIRES_AT),
|
||||
expires_at: TEST_EXPIRES_AT,
|
||||
value: 'secret',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k2',
|
||||
created_at: new Date(TEST_CREATED_UPDATED),
|
||||
updated_at: new Date(TEST_CREATED_UPDATED),
|
||||
created_at: TEST_CREATED_UPDATED,
|
||||
updated_at: TEST_CREATED_UPDATED,
|
||||
tags: [],
|
||||
limits: [
|
||||
{
|
||||
@@ -238,12 +238,12 @@ describe('MultiIngestionSettings Page', () => {
|
||||
keys: [
|
||||
{
|
||||
name: KEY_NAME,
|
||||
expires_at: new Date(TEST_EXPIRES_AT),
|
||||
expires_at: TEST_EXPIRES_AT,
|
||||
value: 'secret',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k1',
|
||||
created_at: new Date(TEST_CREATED_UPDATED),
|
||||
updated_at: new Date(TEST_CREATED_UPDATED),
|
||||
created_at: TEST_CREATED_UPDATED,
|
||||
updated_at: TEST_CREATED_UPDATED,
|
||||
tags: [],
|
||||
limits: [
|
||||
{
|
||||
@@ -299,12 +299,12 @@ describe('MultiIngestionSettings Page', () => {
|
||||
keys: [
|
||||
{
|
||||
name: 'Key Regular',
|
||||
expires_at: new Date(TEST_EXPIRES_AT),
|
||||
expires_at: TEST_EXPIRES_AT,
|
||||
value: 'secret1',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k1',
|
||||
created_at: new Date(TEST_CREATED_UPDATED),
|
||||
updated_at: new Date(TEST_CREATED_UPDATED),
|
||||
created_at: TEST_CREATED_UPDATED,
|
||||
updated_at: TEST_CREATED_UPDATED,
|
||||
tags: [],
|
||||
limits: [],
|
||||
},
|
||||
@@ -319,12 +319,12 @@ describe('MultiIngestionSettings Page', () => {
|
||||
keys: [
|
||||
{
|
||||
name: 'Key Search Result',
|
||||
expires_at: new Date(TEST_EXPIRES_AT),
|
||||
expires_at: TEST_EXPIRES_AT,
|
||||
value: 'secret2',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k2',
|
||||
created_at: new Date(TEST_CREATED_UPDATED),
|
||||
updated_at: new Date(TEST_CREATED_UPDATED),
|
||||
created_at: TEST_CREATED_UPDATED,
|
||||
updated_at: TEST_CREATED_UPDATED,
|
||||
tags: [],
|
||||
limits: [],
|
||||
},
|
||||
|
||||
@@ -13,9 +13,9 @@ describe('filterAlerts', () => {
|
||||
const mockAlertBase: Partial<RuletypesRuleDTO> = {
|
||||
state: 'active' as RuletypesAlertStateDTO,
|
||||
disabled: false,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
createdBy: 'test-user',
|
||||
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
updatedBy: 'test-user',
|
||||
version: '1',
|
||||
condition: {
|
||||
|
||||
@@ -20,7 +20,7 @@ const mockUsers: TypesUserDTO[] = [
|
||||
displayName: 'Alice Smith',
|
||||
email: 'alice@signoz.io',
|
||||
status: 'active',
|
||||
createdAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
orgId: 'org-1',
|
||||
},
|
||||
{
|
||||
@@ -28,7 +28,7 @@ const mockUsers: TypesUserDTO[] = [
|
||||
displayName: 'Bob Jones',
|
||||
email: 'bob@signoz.io',
|
||||
status: 'active',
|
||||
createdAt: new Date('2024-01-02T00:00:00.000Z'),
|
||||
createdAt: '2024-01-02T00:00:00.000Z',
|
||||
orgId: 'org-1',
|
||||
},
|
||||
{
|
||||
@@ -36,7 +36,7 @@ const mockUsers: TypesUserDTO[] = [
|
||||
displayName: '',
|
||||
email: 'charlie@signoz.io',
|
||||
status: 'pending_invite',
|
||||
createdAt: new Date('2024-01-03T00:00:00.000Z'),
|
||||
createdAt: '2024-01-03T00:00:00.000Z',
|
||||
orgId: 'org-1',
|
||||
},
|
||||
{
|
||||
@@ -44,7 +44,7 @@ const mockUsers: TypesUserDTO[] = [
|
||||
displayName: 'Dave Deleted',
|
||||
email: 'dave@signoz.io',
|
||||
status: 'deleted',
|
||||
createdAt: new Date('2024-01-04T00:00:00.000Z'),
|
||||
createdAt: '2024-01-04T00:00:00.000Z',
|
||||
orgId: 'org-1',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { SolidAlertTriangle } from '@signozhq/icons';
|
||||
import { ConfirmDialog } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
export interface DiscardChangesModalProps {
|
||||
open: boolean;
|
||||
isNewPanel: boolean;
|
||||
panelTitle?: string;
|
||||
dashboardTitle?: string;
|
||||
onDiscard: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function DiscardChangesModal({
|
||||
open,
|
||||
isNewPanel,
|
||||
panelTitle,
|
||||
dashboardTitle,
|
||||
onDiscard,
|
||||
onClose,
|
||||
}: DiscardChangesModalProps): JSX.Element {
|
||||
const dashboardName = dashboardTitle ? (
|
||||
<>
|
||||
{' '}
|
||||
to <strong>{dashboardTitle}</strong>
|
||||
</>
|
||||
) : null;
|
||||
const panelLabel = panelTitle ? <strong>{panelTitle}</strong> : 'this panel';
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Discard changes?"
|
||||
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
|
||||
confirmText="Discard"
|
||||
confirmColor="destructive"
|
||||
cancelText="Keep editing"
|
||||
onConfirm={onDiscard}
|
||||
onCancel={onClose}
|
||||
>
|
||||
{isNewPanel ? (
|
||||
<Typography>This new panel won't be added{dashboardName}.</Typography>
|
||||
) : (
|
||||
<Typography>Your unsaved edits to {panelLabel} will be lost.</Typography>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import {
|
||||
initialAutocompleteData,
|
||||
initialQueryBuilderFormValuesMap,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PartialPanelTypes } from '../utils';
|
||||
import { handleQueryChange } from '../utils';
|
||||
import { getIsQueryModified, handleQueryChange } from '../utils';
|
||||
|
||||
const buildSupersetQuery = (extras?: Record<string, unknown>): Query => ({
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
@@ -37,6 +41,128 @@ const buildSupersetQuery = (extras?: Record<string, unknown>): Query => ({
|
||||
},
|
||||
});
|
||||
|
||||
const buildMetricsQuery = (
|
||||
overrides?: Partial<{
|
||||
metricName: string;
|
||||
aggregateAttributeKey: string;
|
||||
legend: string;
|
||||
groupByKey: string;
|
||||
}>,
|
||||
): Query => ({
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
id: 'query-id',
|
||||
unit: '',
|
||||
builder: {
|
||||
queryFormulas: [],
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap[DataSource.METRICS],
|
||||
queryName: 'A',
|
||||
aggregateAttribute: overrides?.aggregateAttributeKey
|
||||
? {
|
||||
...initialAutocompleteData,
|
||||
key: overrides.aggregateAttributeKey,
|
||||
type: 'tag',
|
||||
dataType: DataTypes.Float64,
|
||||
}
|
||||
: cloneDeep(initialAutocompleteData),
|
||||
aggregations: [
|
||||
{
|
||||
metricName: overrides?.metricName ?? 'system.cpu.load',
|
||||
temporality: '',
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
reduceTo: 'avg',
|
||||
} as MetricAggregation,
|
||||
],
|
||||
legend: overrides?.legend ?? '',
|
||||
groupBy: overrides?.groupByKey
|
||||
? [
|
||||
{
|
||||
...initialAutocompleteData,
|
||||
key: overrides.groupByKey,
|
||||
type: 'tag',
|
||||
dataType: DataTypes.String,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
});
|
||||
|
||||
describe('getIsQueryModified', () => {
|
||||
it('returns false when baseline is null (new unsaved panel with no edits anchor)', () => {
|
||||
const current = buildMetricsQuery();
|
||||
expect(getIsQueryModified(current, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when baseline is undefined', () => {
|
||||
const current = buildMetricsQuery();
|
||||
expect(getIsQueryModified(current, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when current only differs by auto-backfilled aggregateAttribute', () => {
|
||||
// saved widget query: aggregateAttribute is the v5-style empty initial value
|
||||
// (stripped from persisted spec; spread back in as initialAutocompleteData on load)
|
||||
const savedQuery = buildMetricsQuery({ metricName: 'system.cpu.load' });
|
||||
// after MetricNameSelector edit-mode backfill, currentQuery has the populated
|
||||
// aggregateAttribute while the rest of the query is identical
|
||||
const currentQuery = buildMetricsQuery({
|
||||
metricName: 'system.cpu.load',
|
||||
aggregateAttributeKey: 'system.cpu.load',
|
||||
});
|
||||
expect(getIsQueryModified(currentQuery, savedQuery)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when the user edits the legend', () => {
|
||||
const baseline = buildMetricsQuery({ metricName: 'system.cpu.load' });
|
||||
const edited = buildMetricsQuery({
|
||||
metricName: 'system.cpu.load',
|
||||
legend: 'cpu-load',
|
||||
});
|
||||
expect(getIsQueryModified(edited, baseline)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when the user picks a different metric (aggregations diverges)', () => {
|
||||
const baseline = buildMetricsQuery({ metricName: 'system.cpu.load' });
|
||||
const edited = buildMetricsQuery({ metricName: 'system.memory.usage' });
|
||||
expect(getIsQueryModified(edited, baseline)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when the user adds a groupBy', () => {
|
||||
const baseline = buildMetricsQuery({ metricName: 'system.cpu.load' });
|
||||
const edited = buildMetricsQuery({
|
||||
metricName: 'system.cpu.load',
|
||||
groupByKey: 'host.name',
|
||||
});
|
||||
expect(getIsQueryModified(edited, baseline)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true on existing widget when current diverges from saved (Stage-and-Run silent-loss flow)', () => {
|
||||
// After Edit → Stage and Run, stagedQuery is reset to match currentQuery.
|
||||
// The dirty check must compare against the SAVED widget query, not stagedQuery.
|
||||
const savedQuery = buildMetricsQuery({ metricName: 'system.cpu.load' });
|
||||
const currentQuery = buildMetricsQuery({ metricName: 'system.memory.usage' });
|
||||
expect(getIsQueryModified(currentQuery, savedQuery)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a new panel where currentQuery still matches stagedQuery baseline', () => {
|
||||
const stagedQuery = buildMetricsQuery();
|
||||
const currentQuery = buildMetricsQuery();
|
||||
expect(getIsQueryModified(currentQuery, stagedQuery)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a new panel where currentQuery has been edited away from stagedQuery', () => {
|
||||
const stagedQuery = buildMetricsQuery();
|
||||
const currentQuery = buildMetricsQuery({ legend: 'custom' });
|
||||
expect(getIsQueryModified(currentQuery, stagedQuery)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQueryChange', () => {
|
||||
it('sets list-specific fields when switching to LIST', () => {
|
||||
const superset = buildSupersetQuery();
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Check, SolidAlertTriangle, X } from '@signozhq/icons';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@signozhq/ui/resizable';
|
||||
import { Flex, Modal, Space } from 'antd';
|
||||
import { Flex } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
@@ -69,7 +68,6 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { getGraphType, getGraphTypeForFormat } from 'utils/getGraphType';
|
||||
|
||||
import LeftContainer from './LeftContainer';
|
||||
import QueryTypeTag from './LeftContainer/QueryTypeTag';
|
||||
import RightContainer from './RightContainer';
|
||||
import { ThresholdProps } from './RightContainer/Threshold/types';
|
||||
import TimeItems, { timePreferance } from './RightContainer/timeItems';
|
||||
@@ -82,6 +80,7 @@ import {
|
||||
placeWidgetAtBottom,
|
||||
placeWidgetBetweenRows,
|
||||
} from './utils';
|
||||
import DiscardChangesModal from './WidgetModals/DiscardChangesModal';
|
||||
|
||||
import './NewWidget.styles.scss';
|
||||
|
||||
@@ -98,8 +97,6 @@ function NewWidget({
|
||||
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
|
||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||
|
||||
const {
|
||||
@@ -110,11 +107,6 @@ function NewWidget({
|
||||
setSupersetQuery,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const isQueryModified = useMemo(
|
||||
() => getIsQueryModified(currentQuery, stagedQuery),
|
||||
[currentQuery, stagedQuery],
|
||||
);
|
||||
|
||||
const { selectedTime: globalSelectedInterval } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
@@ -139,6 +131,23 @@ function NewWidget({
|
||||
|
||||
const query = useUrlQuery();
|
||||
|
||||
// For existing widgets, compare currentQuery against the saved widget query
|
||||
// (stable across Stage-and-Run cycles). For new panels with no saved baseline,
|
||||
// fall back to stagedQuery so initial edits still trigger the warning.
|
||||
const savedWidgetQuery = useMemo(() => {
|
||||
const widgetId = query.get('widgetId');
|
||||
const match = widgets?.find((w) => w.id === widgetId);
|
||||
if (!match || match.panelTypes === PANEL_GROUP_TYPES.ROW) {
|
||||
return null;
|
||||
}
|
||||
return (match as Widgets).query ?? null;
|
||||
}, [widgets, query]);
|
||||
|
||||
const isQueryModified = useMemo(
|
||||
() => getIsQueryModified(currentQuery, savedWidgetQuery ?? stagedQuery),
|
||||
[currentQuery, savedWidgetQuery, stagedQuery],
|
||||
);
|
||||
|
||||
const [isNewDashboard, setIsNewDashboard] = useState<boolean>(false);
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
@@ -228,7 +237,6 @@ function NewWidget({
|
||||
Record<string, string>
|
||||
>(selectedWidget?.customLegendColors || {});
|
||||
|
||||
const [saveModal, setSaveModal] = useState(false);
|
||||
const [discardModal, setDiscardModal] = useState(false);
|
||||
|
||||
const [bucketWidth, setBucketWidth] = useState<number>(
|
||||
@@ -340,7 +348,6 @@ function NewWidget({
|
||||
]);
|
||||
|
||||
const closeModal = (): void => {
|
||||
setSaveModal(false);
|
||||
setDiscardModal(false);
|
||||
};
|
||||
|
||||
@@ -593,7 +600,7 @@ function NewWidget({
|
||||
},
|
||||
};
|
||||
|
||||
updateDashboardMutation.mutateAsync(dashboard, {
|
||||
return updateDashboardMutation.mutateAsync(dashboard, {
|
||||
onSuccess: () => {
|
||||
setToScrollWidgetId(selectedWidget?.id || '');
|
||||
navigateToDashboardPage();
|
||||
@@ -688,9 +695,9 @@ function NewWidget({
|
||||
})),
|
||||
}),
|
||||
});
|
||||
setSaveModal(true);
|
||||
onClickSaveHandler();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isNewPanel]);
|
||||
}, [onClickSaveHandler]);
|
||||
|
||||
const isNewTraceLogsAvailable =
|
||||
currentQuery.queryType === EQueryType.QUERY_BUILDER &&
|
||||
@@ -951,57 +958,14 @@ function NewWidget({
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</PanelContainer>
|
||||
<Modal
|
||||
title={
|
||||
isQueryModified ? (
|
||||
<Space>
|
||||
<SolidAlertTriangle size={16} color="#fdd600" />
|
||||
Unsaved Changes
|
||||
</Space>
|
||||
) : (
|
||||
'Save Widget'
|
||||
)
|
||||
}
|
||||
focusTriggerAfterClose
|
||||
forceRender
|
||||
destroyOnClose
|
||||
closable
|
||||
onCancel={closeModal}
|
||||
onOk={onClickSaveHandler}
|
||||
confirmLoading={updateDashboardMutation.isLoading}
|
||||
centered
|
||||
open={saveModal}
|
||||
width={600}
|
||||
>
|
||||
{!isQueryModified ? (
|
||||
<Typography>
|
||||
{t('your_graph_build_with')}{' '}
|
||||
<QueryTypeTag queryType={currentQuery.queryType} />{' '}
|
||||
{t('dashboard_ok_confirm')}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography>{t('dashboard_unsave_changes')} </Typography>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<SolidAlertTriangle size={16} color="#fdd600" />
|
||||
Unsaved Changes
|
||||
</Space>
|
||||
}
|
||||
focusTriggerAfterClose
|
||||
forceRender
|
||||
destroyOnClose
|
||||
closable
|
||||
onCancel={closeModal}
|
||||
onOk={discardChanges}
|
||||
centered
|
||||
<DiscardChangesModal
|
||||
open={discardModal}
|
||||
width={600}
|
||||
>
|
||||
<Typography>{t('dashboard_unsave_changes')}</Typography>
|
||||
</Modal>
|
||||
isNewPanel={isNewPanel}
|
||||
panelTitle={title}
|
||||
dashboardTitle={dashboardData?.data?.title}
|
||||
onDiscard={discardChanges}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Layout } from 'react-grid-layout';
|
||||
import { omitIdFromQuery } from 'components/ExplorerCard/utils';
|
||||
import { PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import { YAxisCategoryNames } from 'components/YAxisUnitSelector/constants';
|
||||
import {
|
||||
@@ -26,16 +25,84 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { getCategoryName } from './RightContainer/dataFormatCategories';
|
||||
|
||||
// Asks "would saving the current panel change the persisted widget spec?".
|
||||
//
|
||||
// `adjustQueryForV5` is deliberately not reused here: in addition to stripping
|
||||
// the legacy v4 fields, it also resurrects them onto each metric
|
||||
// `aggregations[i]`. That migration step is correct on save but bleeds
|
||||
// asymmetrically across a comparator — the live query still carries the
|
||||
// legacy defaults from `initialQueryBuilderFormValuesMap` while a previously
|
||||
// saved widget had them stripped.
|
||||
const stripQueryDataForCompare = (
|
||||
queryData: IBuilderQuery,
|
||||
): Record<string, unknown> => {
|
||||
const {
|
||||
aggregateAttribute: _aggregateAttribute,
|
||||
aggregateOperator: _aggregateOperator,
|
||||
timeAggregation: _timeAggregation,
|
||||
spaceAggregation: _spaceAggregation,
|
||||
reduceTo: _reduceTo,
|
||||
filters: _filters,
|
||||
...retained
|
||||
} = queryData ?? ({} as IBuilderQuery);
|
||||
|
||||
const groupBy = (retained.groupBy ?? []).map((entry) => {
|
||||
const { id: _id, ...rest } = entry;
|
||||
return rest;
|
||||
});
|
||||
|
||||
return {
|
||||
...retained,
|
||||
groupBy,
|
||||
source: retained.source || '',
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeForDirtyCheck = (query: Query): Record<string, unknown> => {
|
||||
const { id: _id, unit, builder, ...rest } = query;
|
||||
return {
|
||||
...rest,
|
||||
// `id` is regenerated on every Stage and Run; `unit` flips between ''
|
||||
// and undefined depending on whether the user has touched the selector.
|
||||
unit: unit || '',
|
||||
builder: {
|
||||
...builder,
|
||||
queryData: (builder?.queryData ?? []).map(stripQueryDataForCompare),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// `lodash.isEqual` distinguishes `{a: undefined}` from `{}`; for the dirty
|
||||
// check those are the same. Initial-values spreads on the live query
|
||||
// frequently leave such explicit-undefined keys.
|
||||
const stripUndefined = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(stripUndefined);
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
Object.entries(value as Record<string, unknown>).forEach(([k, v]) => {
|
||||
if (v === undefined) {
|
||||
return;
|
||||
}
|
||||
out[k] = stripUndefined(v);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const getIsQueryModified = (
|
||||
currentQuery: Query,
|
||||
stagedQuery: Query | null,
|
||||
baselineQuery: Query | null | undefined,
|
||||
): boolean => {
|
||||
if (!stagedQuery) {
|
||||
if (!baselineQuery) {
|
||||
return false;
|
||||
}
|
||||
const omitIdFromStageQuery = omitIdFromQuery(stagedQuery);
|
||||
const omitIdFromCurrentQuery = omitIdFromQuery(currentQuery);
|
||||
return !isEqual(omitIdFromStageQuery, omitIdFromCurrentQuery);
|
||||
return !isEqual(
|
||||
stripUndefined(normalizeForDirtyCheck(baselineQuery)),
|
||||
stripUndefined(normalizeForDirtyCheck(currentQuery)),
|
||||
);
|
||||
};
|
||||
|
||||
export type PartialPanelTypes = {
|
||||
|
||||
@@ -24,7 +24,7 @@ import { PlannedDowntimeDeleteModal } from './PlannedDowntimeDeleteModal';
|
||||
import { PlannedDowntimeForm } from './PlannedDowntimeForm';
|
||||
import { PlannedDowntimeList } from './PlannedDowntimeList';
|
||||
import {
|
||||
defautlInitialValues,
|
||||
defaultInitialValues,
|
||||
deleteDowntimeHandler,
|
||||
} from './PlannedDowntimeutils';
|
||||
|
||||
@@ -48,9 +48,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const [initialValues, setInitialValues] =
|
||||
useState<Partial<RuletypesPlannedMaintenanceDTO & { editMode: boolean }>>(
|
||||
defautlInitialValues,
|
||||
);
|
||||
useState<Partial<RuletypesPlannedMaintenanceDTO>>(defaultInitialValues);
|
||||
|
||||
const downtimeSchedules = useListDowntimeSchedules();
|
||||
const alertOptions = React.useMemo(
|
||||
@@ -148,7 +146,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={(): void => {
|
||||
setInitialValues({ ...defautlInitialValues, editMode: false });
|
||||
setInitialValues(defaultInitialValues);
|
||||
setIsOpen(true);
|
||||
setEditMode(false);
|
||||
form.resetFields();
|
||||
|
||||
@@ -46,8 +46,6 @@ import { AlertRuleTags } from './PlannedDowntimeList';
|
||||
import {
|
||||
getAlertOptionsFromIds,
|
||||
getDurationInfo,
|
||||
getEndTime,
|
||||
handleTimeConversion,
|
||||
isScheduleRecurring,
|
||||
recurrenceOptions,
|
||||
recurrenceOptionWithSubmenu,
|
||||
@@ -64,11 +62,19 @@ const TIME_FORMAT = DATE_TIME_FORMATS.TIME;
|
||||
const DATE_FORMAT = DATE_TIME_FORMATS.ORDINAL_DATE;
|
||||
const ORDINAL_FORMAT = DATE_TIME_FORMATS.ORDINAL_ONLY;
|
||||
|
||||
const TZ_OPTIONS: DefaultOptionType[] = ALL_TIME_ZONES.map(
|
||||
(timezone: string) => ({
|
||||
label: timezone,
|
||||
value: timezone,
|
||||
key: timezone,
|
||||
}),
|
||||
);
|
||||
|
||||
interface PlannedDowntimeFormData {
|
||||
name: string;
|
||||
startTime: dayjs.Dayjs | string;
|
||||
endTime: dayjs.Dayjs | string;
|
||||
recurrence?: RuletypesRecurrenceDTO | null;
|
||||
startTime: dayjs.Dayjs | null;
|
||||
endTime: dayjs.Dayjs | null;
|
||||
recurrence?: RuletypesRecurrenceDTO;
|
||||
alertRules: DefaultOptionType[];
|
||||
recurrenceSelect?: RuletypesRecurrenceDTO;
|
||||
timezone?: string;
|
||||
@@ -77,11 +83,7 @@ interface PlannedDowntimeFormData {
|
||||
const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
|
||||
|
||||
interface PlannedDowntimeFormProps {
|
||||
initialValues: Partial<
|
||||
RuletypesPlannedMaintenanceDTO & {
|
||||
editMode: boolean;
|
||||
}
|
||||
>;
|
||||
initialValues: Partial<RuletypesPlannedMaintenanceDTO>;
|
||||
alertOptions: DefaultOptionType[];
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
@@ -89,7 +91,7 @@ interface PlannedDowntimeFormProps {
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refetchAllSchedules: () => void;
|
||||
isEditMode: boolean;
|
||||
form: FormInstance<any>;
|
||||
form: FormInstance;
|
||||
}
|
||||
|
||||
export function PlannedDowntimeForm(
|
||||
@@ -107,66 +109,46 @@ export function PlannedDowntimeForm(
|
||||
form,
|
||||
} = props;
|
||||
|
||||
const [selectedTags, setSelectedTags] = React.useState<
|
||||
DefaultOptionType | DefaultOptionType[]
|
||||
>([]);
|
||||
const [selectedTags, setSelectedTags] = React.useState<DefaultOptionType[]>(
|
||||
[],
|
||||
);
|
||||
const alertRuleFormName = 'alertRules';
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [durationUnit, setDurationUnit] = useState<string>(
|
||||
getDurationInfo(initialValues.schedule?.recurrence?.duration as string)
|
||||
?.unit || 'm',
|
||||
getDurationInfo(initialValues.schedule?.recurrence?.duration)?.unit || 'm',
|
||||
);
|
||||
|
||||
const [formData, setFormData] = useState<Partial<PlannedDowntimeFormData>>({
|
||||
timezone: initialValues.schedule?.timezone,
|
||||
});
|
||||
|
||||
const [recurrenceType, setRecurrenceType] = useState<string | null>(
|
||||
(initialValues.schedule?.recurrence?.repeatType as string) ||
|
||||
const [recurrenceType, setRecurrenceType] = useState<string>(
|
||||
initialValues.schedule?.recurrence?.repeatType ||
|
||||
recurrenceOptions.doesNotRepeat.value,
|
||||
);
|
||||
|
||||
const timezoneInitialValue = !isEmpty(initialValues.schedule?.timezone)
|
||||
? (initialValues.schedule?.timezone as string)
|
||||
: undefined;
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const requiredFieldRule = [{ required: true }];
|
||||
|
||||
const datePickerFooter = (mode: any): any =>
|
||||
mode === 'time' ? (
|
||||
<span style={{ color: 'gray' }}>Please select the time</span>
|
||||
) : null;
|
||||
|
||||
const saveHanlder = useCallback(
|
||||
const saveHandler = useCallback(
|
||||
async (values: PlannedDowntimeFormData) => {
|
||||
const shouldKeepLocalTime = !isEditMode;
|
||||
const data: RuletypesPostablePlannedMaintenanceDTO = {
|
||||
alertIds: values.alertRules
|
||||
.map((alert) => alert.value)
|
||||
.filter((alert) => alert !== undefined) as string[],
|
||||
name: values.name,
|
||||
schedule: {
|
||||
startTime: new Date(
|
||||
handleTimeConversion(
|
||||
values.startTime,
|
||||
timezoneInitialValue,
|
||||
values.timezone,
|
||||
shouldKeepLocalTime,
|
||||
),
|
||||
),
|
||||
timezone: values.timezone as string,
|
||||
endTime: values.endTime
|
||||
? new Date(
|
||||
handleTimeConversion(
|
||||
values.endTime,
|
||||
timezoneInitialValue,
|
||||
values.timezone,
|
||||
shouldKeepLocalTime,
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
recurrence: values.recurrence as RuletypesRecurrenceDTO,
|
||||
startTime: values.startTime?.format(),
|
||||
endTime: values.endTime?.format(),
|
||||
timezone: values.timezone!,
|
||||
recurrence: values.recurrence,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -198,50 +180,58 @@ export function PlannedDowntimeForm(
|
||||
notifications,
|
||||
refetchAllSchedules,
|
||||
setIsOpen,
|
||||
timezoneInitialValue,
|
||||
showErrorModal,
|
||||
],
|
||||
);
|
||||
const onFinish = async (values: PlannedDowntimeFormData): Promise<void> => {
|
||||
const { recurrence } = values;
|
||||
const recurrenceData =
|
||||
values?.recurrence?.repeatType === recurrenceOptions.doesNotRepeat.value
|
||||
!recurrence ||
|
||||
recurrence.repeatType === recurrenceOptions.doesNotRepeat.value
|
||||
? undefined
|
||||
: {
|
||||
duration: values.recurrence?.duration
|
||||
? `${values.recurrence?.duration}${durationUnit}`
|
||||
: undefined,
|
||||
endTime: !isEmpty(values.endTime)
|
||||
? handleTimeConversion(
|
||||
values.endTime,
|
||||
timezoneInitialValue,
|
||||
values.timezone,
|
||||
!isEditMode,
|
||||
)
|
||||
: undefined,
|
||||
startTime: handleTimeConversion(
|
||||
values.startTime,
|
||||
timezoneInitialValue,
|
||||
values.timezone,
|
||||
!isEditMode,
|
||||
),
|
||||
repeatOn: !values.recurrence?.repeatOn?.length
|
||||
? undefined
|
||||
: values.recurrence?.repeatOn,
|
||||
repeatType: values.recurrence?.repeatType,
|
||||
duration: recurrence.duration
|
||||
? `${recurrence.duration}${durationUnit}`
|
||||
: '',
|
||||
startTime: values.startTime!.format(),
|
||||
endTime: values.endTime?.format(),
|
||||
repeatOn: recurrence.repeatOn,
|
||||
repeatType: recurrence.repeatType,
|
||||
};
|
||||
|
||||
const payloadValues = {
|
||||
await saveHandler({
|
||||
...values,
|
||||
recurrence: recurrenceData as RuletypesRecurrenceDTO | undefined,
|
||||
};
|
||||
await saveHanlder(payloadValues);
|
||||
recurrence: recurrenceData,
|
||||
});
|
||||
};
|
||||
|
||||
const formValidationRules = [
|
||||
{
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
const handleFormData = (data: Partial<PlannedDowntimeFormData>): void => {
|
||||
const { startTime, endTime, timezone } = data;
|
||||
const update: Partial<PlannedDowntimeFormData> = {};
|
||||
|
||||
// If the set timezone doesn't match, update it.
|
||||
if (
|
||||
startTime &&
|
||||
timezone &&
|
||||
startTime.format() !== startTime.tz(timezone, true).format()
|
||||
) {
|
||||
update.startTime = startTime.tz(timezone, true);
|
||||
}
|
||||
if (
|
||||
endTime &&
|
||||
timezone &&
|
||||
endTime.format() !== endTime.tz(timezone, true).format()
|
||||
) {
|
||||
update.endTime = endTime.tz(timezone, true);
|
||||
}
|
||||
|
||||
if (!isEmpty(update)) {
|
||||
data = { ...data, ...update };
|
||||
form.setFieldsValue({ ...update });
|
||||
}
|
||||
|
||||
setFormData(data);
|
||||
};
|
||||
|
||||
const handleOk = async (): Promise<void> => {
|
||||
await form.validateFields().catch(() => {
|
||||
@@ -249,16 +239,11 @@ export function PlannedDowntimeForm(
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
const handleCancel = (): void => setIsOpen(false);
|
||||
|
||||
const handleChange = (
|
||||
_value: string,
|
||||
options: DefaultOptionType | DefaultOptionType[],
|
||||
): void => {
|
||||
const handleAlertRulesChange: SelectProps['onChange'] = (_value, options) => {
|
||||
form.setFieldValue(alertRuleFormName, options);
|
||||
setSelectedTags(options);
|
||||
setSelectedTags(Array.isArray(options) ? options : [options]);
|
||||
};
|
||||
|
||||
const noTagRenderer: SelectProps['tagRender'] = () => <></>;
|
||||
@@ -267,113 +252,51 @@ export function PlannedDowntimeForm(
|
||||
if (!removedTag) {
|
||||
return;
|
||||
}
|
||||
const newTags = selectedTags.filter(
|
||||
(tag: DefaultOptionType) => tag.value !== removedTag,
|
||||
);
|
||||
const newTags = selectedTags.filter((tag) => tag.value !== removedTag);
|
||||
form.setFieldValue(alertRuleFormName, newTags);
|
||||
setSelectedTags(newTags);
|
||||
};
|
||||
|
||||
const formatedInitialValues = useMemo(() => {
|
||||
const formData: PlannedDowntimeFormData = {
|
||||
const formattedInitialValues = useMemo((): PlannedDowntimeFormData => {
|
||||
const { schedule } = initialValues;
|
||||
const startTime = schedule?.recurrence?.startTime || schedule?.startTime;
|
||||
const endTime = schedule?.recurrence?.endTime || schedule?.endTime;
|
||||
|
||||
return {
|
||||
name: defaultTo(initialValues.name, ''),
|
||||
alertRules: getAlertOptionsFromIds(
|
||||
initialValues.alertIds || [],
|
||||
alertOptions,
|
||||
),
|
||||
endTime: getEndTime(initialValues) ? dayjs(getEndTime(initialValues)) : '',
|
||||
startTime: initialValues.schedule?.startTime
|
||||
? dayjs(initialValues.schedule?.startTime)
|
||||
: '',
|
||||
startTime: startTime ? dayjs(startTime).tz(schedule.timezone) : null,
|
||||
endTime: endTime ? dayjs(endTime).tz(schedule.timezone) : null,
|
||||
recurrence: {
|
||||
...initialValues.schedule?.recurrence,
|
||||
repeatType: (!isScheduleRecurring(initialValues?.schedule)
|
||||
...schedule?.recurrence,
|
||||
repeatType: !isScheduleRecurring(schedule)
|
||||
? recurrenceOptions.doesNotRepeat.value
|
||||
: initialValues.schedule?.recurrence
|
||||
?.repeatType) as RuletypesRecurrenceDTO['repeatType'],
|
||||
duration: String(
|
||||
getDurationInfo(initialValues.schedule?.recurrence?.duration as string)
|
||||
?.value ?? '',
|
||||
),
|
||||
: schedule?.recurrence?.repeatType,
|
||||
duration: getDurationInfo(schedule?.recurrence?.duration)?.value ?? '',
|
||||
} as RuletypesRecurrenceDTO,
|
||||
timezone: initialValues.schedule?.timezone as string,
|
||||
timezone: schedule?.timezone as string,
|
||||
};
|
||||
return formData;
|
||||
}, [initialValues, alertOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTags(formatedInitialValues.alertRules);
|
||||
form.setFieldsValue({ ...formatedInitialValues });
|
||||
}, [form, formatedInitialValues, initialValues]);
|
||||
|
||||
const timeZoneItems: DefaultOptionType[] = ALL_TIME_ZONES.map(
|
||||
(timezone: string) => ({
|
||||
label: timezone,
|
||||
value: timezone,
|
||||
key: timezone,
|
||||
}),
|
||||
);
|
||||
|
||||
const getTimezoneFormattedTime = (
|
||||
time: string | dayjs.Dayjs,
|
||||
timeZone?: string,
|
||||
isEditMode?: boolean,
|
||||
format?: string,
|
||||
): string => {
|
||||
if (!time) {
|
||||
return '';
|
||||
}
|
||||
if (!timeZone) {
|
||||
return dayjs(time).format(format);
|
||||
}
|
||||
return dayjs(time).tz(timeZone, isEditMode).format(format);
|
||||
};
|
||||
setSelectedTags(formattedInitialValues.alertRules);
|
||||
form.setFieldsValue({ ...formattedInitialValues });
|
||||
}, [form, formattedInitialValues, initialValues]);
|
||||
|
||||
const startTimeText = useMemo((): string => {
|
||||
let startTime = formData?.startTime;
|
||||
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
|
||||
startTime =
|
||||
(formData?.recurrence?.startTime
|
||||
? dayjs(formData.recurrence.startTime).toISOString()
|
||||
: '') ||
|
||||
formData?.startTime ||
|
||||
'';
|
||||
}
|
||||
|
||||
const startTime = formData.startTime;
|
||||
if (!startTime) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (formData.timezone) {
|
||||
startTime = handleTimeConversion(
|
||||
startTime,
|
||||
timezoneInitialValue,
|
||||
formData?.timezone,
|
||||
!isEditMode,
|
||||
);
|
||||
}
|
||||
const daysOfWeek = formData?.recurrence?.repeatOn;
|
||||
const daysOfWeek = formData.recurrence?.repeatOn;
|
||||
|
||||
const formattedStartTime = getTimezoneFormattedTime(
|
||||
startTime,
|
||||
formData.timezone,
|
||||
!isEditMode,
|
||||
TIME_FORMAT,
|
||||
);
|
||||
|
||||
const formattedStartDate = getTimezoneFormattedTime(
|
||||
startTime,
|
||||
formData.timezone,
|
||||
!isEditMode,
|
||||
DATE_FORMAT,
|
||||
);
|
||||
|
||||
const ordinalFormat = getTimezoneFormattedTime(
|
||||
startTime,
|
||||
formData.timezone,
|
||||
!isEditMode,
|
||||
ORDINAL_FORMAT,
|
||||
);
|
||||
const formattedStartTime = startTime.format(TIME_FORMAT);
|
||||
const formattedStartDate = startTime.format(DATE_FORMAT);
|
||||
const ordinalFormat = startTime.format(ORDINAL_FORMAT);
|
||||
|
||||
const formattedDaysOfWeek = daysOfWeek?.join(', ');
|
||||
switch (recurrenceType) {
|
||||
@@ -388,49 +311,18 @@ export function PlannedDowntimeForm(
|
||||
default:
|
||||
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
|
||||
}
|
||||
}, [formData, recurrenceType, isEditMode, timezoneInitialValue]);
|
||||
}, [formData, recurrenceType, timezone]);
|
||||
|
||||
const endTimeText = useMemo((): string => {
|
||||
let endTime = formData?.endTime;
|
||||
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
|
||||
endTime =
|
||||
(formData?.recurrence?.endTime
|
||||
? dayjs(formData.recurrence.endTime).toISOString()
|
||||
: '') || '';
|
||||
|
||||
if (!isEditMode && !endTime) {
|
||||
endTime = formData?.endTime || '';
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = formData.endTime;
|
||||
if (!endTime) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (formData.timezone) {
|
||||
endTime = handleTimeConversion(
|
||||
endTime,
|
||||
timezoneInitialValue,
|
||||
formData?.timezone,
|
||||
!isEditMode,
|
||||
);
|
||||
}
|
||||
|
||||
const formattedEndTime = getTimezoneFormattedTime(
|
||||
endTime,
|
||||
formData.timezone,
|
||||
!isEditMode,
|
||||
TIME_FORMAT,
|
||||
);
|
||||
|
||||
const formattedEndDate = getTimezoneFormattedTime(
|
||||
endTime,
|
||||
formData.timezone,
|
||||
!isEditMode,
|
||||
DATE_FORMAT,
|
||||
);
|
||||
const formattedEndTime = endTime.format(TIME_FORMAT);
|
||||
const formattedEndDate = endTime.format(DATE_FORMAT);
|
||||
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
|
||||
}, [formData, recurrenceType, isEditMode, timezoneInitialValue]);
|
||||
}, [formData, recurrenceType, timezone]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -446,33 +338,28 @@ export function PlannedDowntimeForm(
|
||||
footer={null}
|
||||
>
|
||||
<Form<PlannedDowntimeFormData>
|
||||
name={initialValues.editMode ? 'edit-form' : 'create-form'}
|
||||
name={isEditMode ? 'edit-form' : 'create-form'}
|
||||
form={form}
|
||||
layout="vertical"
|
||||
className="createForm"
|
||||
onFinish={onFinish}
|
||||
onValuesChange={(): void => {
|
||||
setRecurrenceType(form.getFieldValue('recurrence')?.repeatType as string);
|
||||
setFormData(form.getFieldsValue());
|
||||
handleFormData(form.getFieldsValue());
|
||||
}}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item label="Name" name="name" rules={formValidationRules}>
|
||||
<Form.Item label="Name" name="name" rules={requiredFieldRule}>
|
||||
<Input placeholder="e.g. Upgrade downtime" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Starts from"
|
||||
name="startTime"
|
||||
rules={formValidationRules}
|
||||
rules={requiredFieldRule}
|
||||
className={!isEmpty(startTimeText) ? 'formItemWithBullet' : ''}
|
||||
getValueProps={(value): any => ({
|
||||
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
|
||||
})}
|
||||
>
|
||||
<DatePicker
|
||||
format={(date): string =>
|
||||
dayjs(date).tz(timezoneInitialValue).format(customFormat)
|
||||
}
|
||||
format={(date) => date.format(customFormat)}
|
||||
showTime
|
||||
renderExtraFooter={datePickerFooter}
|
||||
showNow={false}
|
||||
@@ -485,7 +372,7 @@ export function PlannedDowntimeForm(
|
||||
<Form.Item
|
||||
label="Repeats every"
|
||||
name={['recurrence', 'repeatType']}
|
||||
rules={formValidationRules}
|
||||
rules={requiredFieldRule}
|
||||
>
|
||||
<Select
|
||||
placeholder="Select option..."
|
||||
@@ -496,7 +383,7 @@ export function PlannedDowntimeForm(
|
||||
<Form.Item
|
||||
label="Weekly occurernce"
|
||||
name={['recurrence', 'repeatOn']}
|
||||
rules={formValidationRules}
|
||||
rules={requiredFieldRule}
|
||||
>
|
||||
<Select
|
||||
placeholder="Select option..."
|
||||
@@ -510,16 +397,14 @@ export function PlannedDowntimeForm(
|
||||
<Form.Item
|
||||
label="Duration"
|
||||
name={['recurrence', 'duration']}
|
||||
rules={formValidationRules}
|
||||
rules={requiredFieldRule}
|
||||
>
|
||||
<Input
|
||||
addonAfter={
|
||||
<Select
|
||||
defaultValue="m"
|
||||
value={durationUnit}
|
||||
onChange={(value): void => {
|
||||
setDurationUnit(value);
|
||||
}}
|
||||
onChange={(value): void => setDurationUnit(value)}
|
||||
>
|
||||
<Select.Option value="m">Mins</Select.Option>
|
||||
<Select.Option value="h">Hours</Select.Option>
|
||||
@@ -533,8 +418,8 @@ export function PlannedDowntimeForm(
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item label="Timezone" name="timezone" rules={formValidationRules}>
|
||||
<Select options={timeZoneItems} placeholder="Select timezone" showSearch />
|
||||
<Form.Item label="Timezone" name="timezone" rules={requiredFieldRule}>
|
||||
<Select options={TZ_OPTIONS} placeholder="Select timezone" showSearch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Ends on"
|
||||
@@ -546,14 +431,9 @@ export function PlannedDowntimeForm(
|
||||
},
|
||||
]}
|
||||
className={!isEmpty(endTimeText) ? 'formItemWithBullet' : ''}
|
||||
getValueProps={(value): any => ({
|
||||
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
|
||||
})}
|
||||
>
|
||||
<DatePicker
|
||||
format={(date): string =>
|
||||
dayjs(date).tz(timezoneInitialValue).format(customFormat)
|
||||
}
|
||||
format={(date) => date.format(customFormat)}
|
||||
showTime
|
||||
showNow={false}
|
||||
renderExtraFooter={datePickerFooter}
|
||||
@@ -584,7 +464,7 @@ export function PlannedDowntimeForm(
|
||||
status={isError ? 'error' : undefined}
|
||||
loading={isLoading}
|
||||
tagRender={noTagRenderer}
|
||||
onChange={handleChange}
|
||||
onChange={handleAlertRulesChange}
|
||||
showSearch
|
||||
options={alertOptions}
|
||||
filterOption={(input, option): boolean =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Collapse, Flex, Space, Table, TableProps, Tag, Tooltip } from 'antd';
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
ListDowntimeSchedules200,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesPlannedMaintenanceDTO,
|
||||
RuletypesRecurrenceDTO,
|
||||
RuletypesScheduleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ErrorType } from 'api/generatedAPIInstance';
|
||||
import cx from 'classnames';
|
||||
@@ -19,12 +19,11 @@ import { CalendarClock, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { showErrorNotification } from '../../utils/error';
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
import {
|
||||
formatDateTime,
|
||||
getAlertOptionsFromIds,
|
||||
getDuration,
|
||||
getEndTime,
|
||||
recurrenceInfo,
|
||||
} from './PlannedDowntimeutils';
|
||||
|
||||
@@ -126,29 +125,28 @@ export function CollapseListContent({
|
||||
created_at,
|
||||
created_by_name,
|
||||
created_by_email,
|
||||
timeframe,
|
||||
repeats,
|
||||
schedule,
|
||||
updated_at,
|
||||
updated_by_name,
|
||||
alertOptions,
|
||||
timezone,
|
||||
}: {
|
||||
created_at?: string;
|
||||
created_by_name?: string;
|
||||
created_by_email?: string;
|
||||
timeframe: [string | undefined | null, string | undefined | null];
|
||||
repeats?: RuletypesRecurrenceDTO | null;
|
||||
schedule?: RuletypesScheduleDTO;
|
||||
updated_at?: string;
|
||||
updated_by_name?: string;
|
||||
alertOptions?: DefaultOptionType[];
|
||||
timezone?: string;
|
||||
}): JSX.Element {
|
||||
const repeats = schedule?.recurrence;
|
||||
const renderItems = (title: string, value: ReactNode): JSX.Element => (
|
||||
<div className="render-item-collapse-list">
|
||||
<Typography>{title}</Typography>
|
||||
<div className="render-item-value">{value}</div>
|
||||
</div>
|
||||
);
|
||||
const startTime = formatDateTime(schedule?.startTime, schedule?.timezone);
|
||||
const endTime = formatDateTime(schedule?.endTime, schedule?.timezone);
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
@@ -183,16 +181,20 @@ export function CollapseListContent({
|
||||
|
||||
{renderItems(
|
||||
'Timeframe',
|
||||
timeframe[0] || timeframe[1] ? (
|
||||
<Typography>{`${formatDateTime(timeframe[0])} ⎯ ${formatDateTime(
|
||||
timeframe[1],
|
||||
)}`}</Typography>
|
||||
schedule?.startTime ? (
|
||||
<Typography>{`${startTime} ⎯ ${endTime}`}</Typography>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
)}
|
||||
{renderItems('Timezone', <Typography>{timezone || '-'}</Typography>)}
|
||||
{renderItems('Repeats', <Typography>{recurrenceInfo(repeats)}</Typography>)}
|
||||
{renderItems(
|
||||
'Timezone',
|
||||
<Typography>{schedule?.timezone || '-'}</Typography>,
|
||||
)}
|
||||
{renderItems(
|
||||
'Repeats',
|
||||
<Typography>{recurrenceInfo(repeats, schedule?.timezone)}</Typography>,
|
||||
)}
|
||||
{renderItems(
|
||||
'Alerts silenced',
|
||||
alertOptions?.length ? (
|
||||
@@ -232,22 +234,12 @@ export function CustomCollapseList(
|
||||
setModalOpen,
|
||||
handleDeleteDowntime,
|
||||
setEditMode,
|
||||
kind,
|
||||
} = props;
|
||||
|
||||
const scheduleTime = schedule?.startTime
|
||||
? dayjs(schedule.startTime).toISOString()
|
||||
: createdAt
|
||||
? dayjs(createdAt).toISOString()
|
||||
: '';
|
||||
// Combine time and date
|
||||
const formattedDateAndTime = `Start time ⎯ ${formatDateTime(
|
||||
defaultTo(scheduleTime, ''),
|
||||
)} ${schedule?.timezone}`;
|
||||
const endTime = getEndTime({
|
||||
kind,
|
||||
schedule,
|
||||
} as Partial<RuletypesPlannedMaintenanceDTO>);
|
||||
? dayjs(schedule.startTime).tz(schedule.timezone)
|
||||
: createdAt || '';
|
||||
const formattedDateAndTime = `Start time ⎯ ${formatDateTime(scheduleTime)} ${schedule?.timezone}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -257,21 +249,16 @@ export function CustomCollapseList(
|
||||
<HeaderComponent
|
||||
duration={
|
||||
schedule?.recurrence?.duration
|
||||
? (schedule?.recurrence?.duration as string)
|
||||
: getDuration(
|
||||
schedule?.startTime ? dayjs(schedule.startTime).toISOString() : '',
|
||||
schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '',
|
||||
)
|
||||
? schedule.recurrence.duration
|
||||
: getDuration(schedule?.startTime || '', schedule?.endTime || '')
|
||||
}
|
||||
name={defaultTo(name, '')}
|
||||
handleEdit={(): void => {
|
||||
handleEdit={() => {
|
||||
setInitialValues({ ...props });
|
||||
setModalOpen(true);
|
||||
setEditMode(true);
|
||||
}}
|
||||
handleDelete={(): void => {
|
||||
handleDeleteDowntime(id ?? '', name || '');
|
||||
}}
|
||||
handleDelete={() => handleDeleteDowntime(id ?? '', name || '')}
|
||||
/>
|
||||
}
|
||||
key={id ?? ''}
|
||||
@@ -279,17 +266,10 @@ export function CustomCollapseList(
|
||||
<CollapseListContent
|
||||
created_at={createdAt ? dayjs(createdAt).toISOString() : ''}
|
||||
created_by_name={defaultTo(createdBy, '')}
|
||||
timeframe={[
|
||||
schedule?.startTime?.toString(),
|
||||
typeof endTime === 'string' ? endTime : endTime?.toString(),
|
||||
]}
|
||||
repeats={
|
||||
schedule?.recurrence as RuletypesRecurrenceDTO | null | undefined
|
||||
}
|
||||
schedule={schedule}
|
||||
updated_at={updatedAt ? dayjs(updatedAt).toISOString() : ''}
|
||||
updated_by_name={defaultTo(updatedBy, '')}
|
||||
alertOptions={alertOptions}
|
||||
timezone={defaultTo(schedule?.timezone, '')}
|
||||
/>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
|
||||
@@ -11,8 +11,8 @@ import type {
|
||||
import type { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { AxiosError } from 'axios';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { isEmpty, isEqual } from 'lodash-es';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
type DateTimeString = string | null | undefined;
|
||||
@@ -38,14 +38,20 @@ export const getDuration = (
|
||||
return `${hours} hours`;
|
||||
};
|
||||
|
||||
export const formatDateTime = (dateTimeString?: string | null): string => {
|
||||
export const formatDateTime = (
|
||||
dateTimeString?: string | Dayjs | null,
|
||||
timezone?: string,
|
||||
): string => {
|
||||
if (!dateTimeString) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
return dayjs(dateTimeString.slice(0, 19)).format(
|
||||
DATE_TIME_FORMATS.MONTH_DATETIME,
|
||||
);
|
||||
let dt = dayjs(dateTimeString);
|
||||
if (timezone) {
|
||||
dt = dt.tz(timezone);
|
||||
}
|
||||
|
||||
return dt.format(DATE_TIME_FORMATS.MONTH_DATETIME);
|
||||
};
|
||||
|
||||
export const getAlertOptionsFromIds = (
|
||||
@@ -61,6 +67,7 @@ export const getAlertOptionsFromIds = (
|
||||
|
||||
export const recurrenceInfo = (
|
||||
recurrence?: RuletypesRecurrenceDTO | null,
|
||||
timezone?: string,
|
||||
): string => {
|
||||
if (!recurrence) {
|
||||
return 'No';
|
||||
@@ -69,10 +76,10 @@ export const recurrenceInfo = (
|
||||
const { startTime, duration, repeatOn, repeatType, endTime } = recurrence;
|
||||
|
||||
const formattedStartTime = startTime
|
||||
? formatDateTime(dayjs(startTime).toISOString())
|
||||
? formatDateTime(startTime, timezone)
|
||||
: '';
|
||||
const formattedEndTime = endTime
|
||||
? `to ${formatDateTime(dayjs(endTime).toISOString())}`
|
||||
? `to ${formatDateTime(endTime, timezone)}`
|
||||
: '';
|
||||
const weeklyRepeatString = repeatOn ? `on ${repeatOn.join(', ')}` : '';
|
||||
const durationString = duration ? `- Duration: ${duration}` : '';
|
||||
@@ -80,9 +87,7 @@ export const recurrenceInfo = (
|
||||
return `Repeats - ${repeatType} ${weeklyRepeatString} from ${formattedStartTime} ${formattedEndTime} ${durationString}`;
|
||||
};
|
||||
|
||||
export const defautlInitialValues: Partial<
|
||||
RuletypesPlannedMaintenanceDTO & { editMode: boolean }
|
||||
> = {
|
||||
export const defaultInitialValues: Partial<RuletypesPlannedMaintenanceDTO> = {
|
||||
name: '',
|
||||
description: '',
|
||||
schedule: {
|
||||
@@ -94,7 +99,6 @@ export const defautlInitialValues: Partial<
|
||||
alertIds: [],
|
||||
createdAt: undefined,
|
||||
createdBy: undefined,
|
||||
editMode: false,
|
||||
};
|
||||
|
||||
type DeleteDowntimeScheduleProps = {
|
||||
@@ -210,75 +214,6 @@ export const recurrenceOptionWithSubmenu: Option[] = [
|
||||
recurrenceOptions.monthly,
|
||||
];
|
||||
|
||||
export const getRecurrenceOptionFromValue = (
|
||||
value?: string | Option | null,
|
||||
): Option | null | undefined => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return Object.values(recurrenceOptions).find(
|
||||
(option) => option.value === value,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const getEndTime = ({
|
||||
kind,
|
||||
schedule,
|
||||
}: Partial<
|
||||
RuletypesPlannedMaintenanceDTO & {
|
||||
editMode: boolean;
|
||||
}
|
||||
>): string | dayjs.Dayjs => {
|
||||
if (kind === 'fixed') {
|
||||
return schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '';
|
||||
}
|
||||
|
||||
return schedule?.recurrence?.endTime
|
||||
? dayjs(schedule.recurrence.endTime).toISOString()
|
||||
: '';
|
||||
};
|
||||
|
||||
export const isScheduleRecurring = (
|
||||
schedule?: RuletypesPlannedMaintenanceDTO['schedule'] | null,
|
||||
): boolean => (schedule ? !isEmpty(schedule?.recurrence) : false);
|
||||
|
||||
function convertUtcOffsetToTimezoneOffset(offsetMinutes: number): string {
|
||||
const sign = offsetMinutes >= 0 ? '+' : '-';
|
||||
const absOffset = Math.abs(offsetMinutes);
|
||||
const hours = String(Math.floor(absOffset / 60)).padStart(2, '0');
|
||||
const minutes = String(absOffset % 60).padStart(2, '0');
|
||||
return `${sign}${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
export function formatWithTimezone(
|
||||
dateValue?: string | dayjs.Dayjs,
|
||||
timezone?: string,
|
||||
): string {
|
||||
const parsedDate =
|
||||
typeof dateValue === 'string' ? dateValue : dateValue?.format();
|
||||
|
||||
// Get the target timezone offset
|
||||
const targetOffset = convertUtcOffsetToTimezoneOffset(
|
||||
dayjs(dateValue).tz(timezone).utcOffset(),
|
||||
);
|
||||
|
||||
return `${parsedDate?.substring(0, 19)}${targetOffset}`;
|
||||
}
|
||||
|
||||
export function handleTimeConversion(
|
||||
dateValue: string | dayjs.Dayjs,
|
||||
timezoneInit?: string,
|
||||
timezone?: string,
|
||||
shouldKeepLocalTime?: boolean,
|
||||
): string {
|
||||
const timezoneChanged = !isEqual(timezoneInit, timezone);
|
||||
const initialTime = dayjs(dateValue).tz(timezoneInit);
|
||||
|
||||
const formattedTime = formatWithTimezone(initialTime, timezone);
|
||||
return timezoneChanged
|
||||
? formattedTime
|
||||
: dayjs(dateValue).tz(timezone, shouldKeepLocalTime).format();
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ const MOCK_DATE_3 = '2024-01-03';
|
||||
const MOCK_DOWNTIME_1 = createMockDowntime({
|
||||
id: '1',
|
||||
name: MOCK_DOWNTIME_1_NAME,
|
||||
createdAt: new Date(MOCK_DATE_1),
|
||||
updatedAt: new Date(MOCK_DATE_1),
|
||||
createdAt: MOCK_DATE_1,
|
||||
updatedAt: MOCK_DATE_1,
|
||||
schedule: buildSchedule({
|
||||
startTime: new Date(MOCK_DATE_1),
|
||||
startTime: MOCK_DATE_1,
|
||||
timezone: 'UTC',
|
||||
}),
|
||||
alertIds: [],
|
||||
@@ -39,10 +39,10 @@ const MOCK_DOWNTIME_1 = createMockDowntime({
|
||||
const MOCK_DOWNTIME_2 = createMockDowntime({
|
||||
id: '2',
|
||||
name: MOCK_DOWNTIME_2_NAME,
|
||||
createdAt: new Date(MOCK_DATE_2),
|
||||
updatedAt: new Date(MOCK_DATE_2),
|
||||
createdAt: MOCK_DATE_2,
|
||||
updatedAt: MOCK_DATE_2,
|
||||
schedule: buildSchedule({
|
||||
startTime: new Date(MOCK_DATE_2),
|
||||
startTime: MOCK_DATE_2,
|
||||
timezone: 'UTC',
|
||||
}),
|
||||
alertIds: [],
|
||||
@@ -51,10 +51,10 @@ const MOCK_DOWNTIME_2 = createMockDowntime({
|
||||
const MOCK_DOWNTIME_3 = createMockDowntime({
|
||||
id: '3',
|
||||
name: MOCK_DOWNTIME_3_NAME,
|
||||
createdAt: new Date(MOCK_DATE_3),
|
||||
updatedAt: new Date(MOCK_DATE_3),
|
||||
createdAt: MOCK_DATE_3,
|
||||
updatedAt: MOCK_DATE_3,
|
||||
schedule: buildSchedule({
|
||||
startTime: new Date(MOCK_DATE_3),
|
||||
startTime: MOCK_DATE_3,
|
||||
timezone: 'UTC',
|
||||
}),
|
||||
alertIds: [],
|
||||
|
||||
@@ -24,7 +24,7 @@ export const createMockDowntime = (
|
||||
description: overrides.description ?? '',
|
||||
schedule: buildSchedule({
|
||||
timezone: 'UTC',
|
||||
startTime: new Date('2024-01-01'),
|
||||
startTime: '2024-01-01',
|
||||
...overrides.schedule,
|
||||
}),
|
||||
alertIds: overrides.alertIds ?? [],
|
||||
|
||||
@@ -16,31 +16,35 @@ enum SpanScope {
|
||||
ENTRYPOINT_SPANS = 'entrypoint_spans',
|
||||
}
|
||||
|
||||
interface SpanFilterConfig {
|
||||
key: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface SpanScopeSelectorProps {
|
||||
onChange?: (value: TagFilter) => void;
|
||||
query?: IBuilderQuery;
|
||||
skipQueryBuilderRedirect?: boolean;
|
||||
}
|
||||
|
||||
const SPAN_FILTER_KEY: Record<SpanScope, string | null> = {
|
||||
const SPAN_FILTER_CONFIG: Record<SpanScope, SpanFilterConfig | null> = {
|
||||
[SpanScope.ALL_SPANS]: null,
|
||||
[SpanScope.ROOT_SPANS]: 'isRoot',
|
||||
[SpanScope.ENTRYPOINT_SPANS]: 'isEntryPoint',
|
||||
[SpanScope.ROOT_SPANS]: {
|
||||
key: 'isRoot',
|
||||
type: 'spanSearchScope',
|
||||
},
|
||||
[SpanScope.ENTRYPOINT_SPANS]: {
|
||||
key: 'isEntryPoint',
|
||||
type: 'spanSearchScope',
|
||||
},
|
||||
};
|
||||
|
||||
const SCOPE_FILTER_KEYS = Object.values(SPAN_FILTER_KEY).filter(
|
||||
(key): key is string => key !== null,
|
||||
);
|
||||
|
||||
const isScopeFilter = (filter: TagFilterItem, key: string): boolean =>
|
||||
filter.key?.key === key && String(filter.value) === 'true';
|
||||
|
||||
const createFilterItem = (key: string): TagFilterItem => ({
|
||||
const createFilterItem = (config: SpanFilterConfig): TagFilterItem => ({
|
||||
id: uuid().slice(0, 8),
|
||||
key: {
|
||||
key,
|
||||
key: config.key,
|
||||
dataType: undefined,
|
||||
type: '',
|
||||
type: config?.type,
|
||||
},
|
||||
op: '=',
|
||||
value: 'true',
|
||||
@@ -66,7 +70,12 @@ function SpanScopeSelector({
|
||||
filters: TagFilterItem[] = [],
|
||||
): SpanScope => {
|
||||
const hasFilter = (key: string): boolean =>
|
||||
filters?.some((filter) => isScopeFilter(filter, key));
|
||||
filters?.some(
|
||||
(filter) =>
|
||||
filter.key?.type === 'spanSearchScope' &&
|
||||
filter.key.key === key &&
|
||||
filter.value === 'true',
|
||||
);
|
||||
|
||||
if (hasFilter('isRoot')) {
|
||||
return SpanScope.ROOT_SPANS;
|
||||
@@ -104,21 +113,28 @@ function SpanScopeSelector({
|
||||
|
||||
const nonScopeFilters = currentFilters.filter(
|
||||
(filter) =>
|
||||
!SCOPE_FILTER_KEYS.some((scopeKey) => isScopeFilter(filter, scopeKey)),
|
||||
!(
|
||||
filter.key?.type === 'spanSearchScope' &&
|
||||
(filter.key.key === 'isRoot' || filter.key.key === 'isEntryPoint')
|
||||
),
|
||||
);
|
||||
|
||||
const scopeKey = SPAN_FILTER_KEY[newScope];
|
||||
const newScopeFilter = scopeKey !== null ? [createFilterItem(scopeKey)] : [];
|
||||
const config = SPAN_FILTER_CONFIG[newScope];
|
||||
const newScopeFilter = config !== null ? [createFilterItem(config)] : [];
|
||||
|
||||
return [...nonScopeFilters, ...newScopeFilter];
|
||||
};
|
||||
|
||||
const keysToRemove = Object.values(SPAN_FILTER_CONFIG)
|
||||
.map((config) => config?.key)
|
||||
.filter((key): key is string => typeof key === 'string');
|
||||
|
||||
newQuery.builder.queryData = newQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeKeysFromExpression(
|
||||
item.filter?.expression ?? '',
|
||||
SCOPE_FILTER_KEYS,
|
||||
keysToRemove,
|
||||
),
|
||||
},
|
||||
filters: {
|
||||
|
||||
@@ -20,16 +20,12 @@ import SpanScopeSelector from '../SpanScopeSelector';
|
||||
|
||||
const mockRedirectWithQueryBuilderData = jest.fn();
|
||||
|
||||
const SCOPE_KEYS = ['isRoot', 'isEntryPoint'];
|
||||
const isScopeFilter = (filter: TagFilterItem): boolean =>
|
||||
SCOPE_KEYS.includes(filter.key?.key ?? '') && String(filter.value) === 'true';
|
||||
|
||||
// Helper to create filter items
|
||||
const createSpanScopeFilter = (key: string): TagFilterItem => ({
|
||||
id: 'span-filter',
|
||||
key: {
|
||||
key,
|
||||
type: '',
|
||||
type: 'spanSearchScope',
|
||||
},
|
||||
op: '=',
|
||||
value: 'true',
|
||||
@@ -147,6 +143,7 @@ describe('SpanScopeSelector', () => {
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({
|
||||
key: expectedKey,
|
||||
type: 'spanSearchScope',
|
||||
}),
|
||||
op: '=',
|
||||
value: 'true',
|
||||
@@ -165,7 +162,11 @@ describe('SpanScopeSelector', () => {
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalled();
|
||||
const updatedQuery = mockRedirectWithQueryBuilderData.mock.calls[0][0];
|
||||
const filters = updatedQuery.builder.queryData[0].filters.items;
|
||||
expect(filters.some(isScopeFilter)).toBe(false);
|
||||
expect(filters).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ type: 'spanSearchScope' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should add isRoot filter when selecting ROOT_SPANS', async () => {
|
||||
@@ -205,27 +206,6 @@ describe('SpanScopeSelector', () => {
|
||||
await expect(screen.findByText(expectedText)).resolves.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
// Round-trip from filter.expression can deserialize the value as a boolean
|
||||
// `true` (unquoted in the expression) instead of the string `'true'` produced
|
||||
// by the dropdown. The dropdown must still recognize that as the scope filter.
|
||||
it.each([
|
||||
['Root Spans', 'isRoot'],
|
||||
['Entrypoint Spans', 'isEntryPoint'],
|
||||
])(
|
||||
'should initialize with %s selected when %s = true (boolean value)',
|
||||
async (expectedText, filterKey) => {
|
||||
const booleanScopeFilter: TagFilterItem = {
|
||||
id: 'span-filter',
|
||||
key: { key: filterKey, type: '' },
|
||||
op: '=',
|
||||
value: true as unknown as string,
|
||||
};
|
||||
const queryWithFilter = createQueryWithFilters([booleanScopeFilter]);
|
||||
renderWithContext(queryWithFilter, undefined, defaultQueryBuilderQuery);
|
||||
await expect(screen.findByText(expectedText)).resolves.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('when onChange and query props are provided', () => {
|
||||
@@ -253,7 +233,9 @@ describe('SpanScopeSelector', () => {
|
||||
expect(items).toContainEqual(nonScopeItem);
|
||||
});
|
||||
|
||||
const scopeFiltersInPayload = items.filter(isScopeFilter);
|
||||
const scopeFiltersInPayload = items.filter(
|
||||
(filter) => filter.key?.type === 'spanSearchScope',
|
||||
);
|
||||
|
||||
if (expectedScopeKey) {
|
||||
expect(scopeFiltersInPayload).toHaveLength(1);
|
||||
@@ -452,7 +434,9 @@ describe('SpanScopeSelector', () => {
|
||||
items: [],
|
||||
};
|
||||
// Count non-scope filters
|
||||
const nonScopeFilters = items.filter((filter) => !isScopeFilter(filter));
|
||||
const nonScopeFilters = items.filter(
|
||||
(filter) => filter.key?.type !== 'spanSearchScope',
|
||||
);
|
||||
expect(nonScopeFilters).toHaveLength(1);
|
||||
|
||||
expect(nonScopeFilters).toContainEqual(
|
||||
|
||||
@@ -5,8 +5,8 @@ const orgId = '019ba2bb-2fa1-7b24-8159-cfca08617ef9';
|
||||
export const managedRoles: AuthtypesRoleDTO[] = [
|
||||
{
|
||||
id: '019c24aa-2248-756f-9833-984f1ab63819',
|
||||
createdAt: new Date('2026-02-03T18:00:55.624356Z'),
|
||||
updatedAt: new Date('2026-02-03T18:00:55.624356Z'),
|
||||
createdAt: '2026-02-03T18:00:55.624356Z',
|
||||
updatedAt: '2026-02-03T18:00:55.624356Z',
|
||||
name: 'signoz-admin',
|
||||
description:
|
||||
'Role assigned to users who have full administrative access to SigNoz resources.',
|
||||
@@ -15,8 +15,8 @@ export const managedRoles: AuthtypesRoleDTO[] = [
|
||||
},
|
||||
{
|
||||
id: '019c24aa-2248-757c-9faf-7b1e899751e0',
|
||||
createdAt: new Date('2026-02-03T18:00:55.624359Z'),
|
||||
updatedAt: new Date('2026-02-03T18:00:55.624359Z'),
|
||||
createdAt: '2026-02-03T18:00:55.624359Z',
|
||||
updatedAt: '2026-02-03T18:00:55.624359Z',
|
||||
name: 'signoz-editor',
|
||||
description:
|
||||
'Role assigned to users who can create, edit, and manage SigNoz resources but do not have full administrative privileges.',
|
||||
@@ -25,8 +25,8 @@ export const managedRoles: AuthtypesRoleDTO[] = [
|
||||
},
|
||||
{
|
||||
id: '019c24aa-2248-7585-a129-4188b3473c27',
|
||||
createdAt: new Date('2026-02-03T18:00:55.624362Z'),
|
||||
updatedAt: new Date('2026-02-03T18:00:55.624362Z'),
|
||||
createdAt: '2026-02-03T18:00:55.624362Z',
|
||||
updatedAt: '2026-02-03T18:00:55.624362Z',
|
||||
name: 'signoz-viewer',
|
||||
description:
|
||||
'Role assigned to users who have read-only access to SigNoz resources.',
|
||||
@@ -38,8 +38,8 @@ export const managedRoles: AuthtypesRoleDTO[] = [
|
||||
export const customRoles: AuthtypesRoleDTO[] = [
|
||||
{
|
||||
id: '019c24aa-3333-0001-aaaa-111111111111',
|
||||
createdAt: new Date('2026-02-10T10:30:00.000Z'),
|
||||
updatedAt: new Date('2026-02-12T14:20:00.000Z'),
|
||||
createdAt: '2026-02-10T10:30:00.000Z',
|
||||
updatedAt: '2026-02-12T14:20:00.000Z',
|
||||
name: 'billing-manager',
|
||||
description: 'Custom role for managing billing and invoices.',
|
||||
type: 'custom',
|
||||
@@ -47,8 +47,8 @@ export const customRoles: AuthtypesRoleDTO[] = [
|
||||
},
|
||||
{
|
||||
id: '019c24aa-3333-0002-bbbb-222222222222',
|
||||
createdAt: new Date('2026-02-11T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-02-13T11:45:00.000Z'),
|
||||
createdAt: '2026-02-11T09:00:00.000Z',
|
||||
updatedAt: '2026-02-13T11:45:00.000Z',
|
||||
name: 'dashboard-creator',
|
||||
description: 'Custom role allowing users to create and manage dashboards.',
|
||||
type: 'custom',
|
||||
|
||||
@@ -112,11 +112,11 @@ export function SpanHoverCard({
|
||||
}
|
||||
const span = spans[idx];
|
||||
const previewRows: SpanPreviewRow[] = previewFields
|
||||
.filter((f) => !RESERVED_PREVIEW_KEYS.has(f.key))
|
||||
.filter((f) => !RESERVED_PREVIEW_KEYS.has(f.name))
|
||||
.map((f) => {
|
||||
const value = getSpanAttribute(span, f.key);
|
||||
const value = getSpanAttribute(span, f.name);
|
||||
return value !== undefined && value !== ''
|
||||
? { key: f.key, value: String(value) }
|
||||
? { key: f.name, value: String(value) }
|
||||
: null;
|
||||
})
|
||||
.filter((r): r is SpanPreviewRow => r !== null);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Skeleton } from 'antd';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import cx from 'classnames';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -23,12 +24,10 @@ import {
|
||||
Server,
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import FieldsSettings from '../components/FieldsSettings/FieldsSettings';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
@@ -226,26 +225,15 @@ function TraceDetailsHeader({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPreviewFieldsOpen && (
|
||||
<FloatingPanel
|
||||
isOpen
|
||||
width={350}
|
||||
height={window.innerHeight - 100}
|
||||
defaultPosition={{
|
||||
x: window.innerWidth - 350 - 100,
|
||||
y: 50,
|
||||
}}
|
||||
enableResizing={false}
|
||||
>
|
||||
<FieldsSettings
|
||||
title="Preview fields"
|
||||
fields={previewFields}
|
||||
onFieldsChange={setPreviewFields}
|
||||
onClose={(): void => setIsPreviewFieldsOpen(false)}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
</FloatingPanel>
|
||||
)}
|
||||
<FieldsSelector
|
||||
isOpen={isPreviewFieldsOpen}
|
||||
title="Preview fields"
|
||||
fields={previewFields}
|
||||
onFieldsChange={setPreviewFields}
|
||||
onClose={(): void => setIsPreviewFieldsOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
maxFields={10}
|
||||
/>
|
||||
|
||||
<AnalyticsPanel
|
||||
isOpen={isAnalyticsOpen}
|
||||
|
||||
@@ -9,10 +9,7 @@ import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { COLOR_BY_FIELDS } from '../constants';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import Error from '../TraceWaterfall/TraceWaterfallStates/Error/Error';
|
||||
import {
|
||||
mergeTelemetryFieldKeys,
|
||||
toTelemetryFieldKey,
|
||||
} from '../utils/previewFields';
|
||||
import { mergeTelemetryFieldKeys } from '../utils/previewFields';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from './constants';
|
||||
import FlamegraphCanvas from './FlamegraphCanvas';
|
||||
import { useVisualLayoutWorker } from './hooks/useVisualLayoutWorker';
|
||||
@@ -60,11 +57,7 @@ function TraceFlamegraph({
|
||||
// Color-by fields baseline + user-picked preview fields. De-duped by `name`,
|
||||
// color-by entries first so their canonical metadata wins on collision.
|
||||
const flamegraphSelectFields = useMemo(
|
||||
() =>
|
||||
mergeTelemetryFieldKeys(
|
||||
COLOR_BY_FIELDS,
|
||||
previewFields.map(toTelemetryFieldKey),
|
||||
),
|
||||
() => mergeTelemetryFieldKeys(COLOR_BY_FIELDS, previewFields),
|
||||
[previewFields],
|
||||
);
|
||||
|
||||
|
||||
@@ -144,14 +144,14 @@ export function useFlamegraphHover(
|
||||
const buildPreviewRows = useCallback(
|
||||
(span: FlamegraphSpan): SpanPreviewRowData[] =>
|
||||
previewFields
|
||||
.filter((field) => !RESERVED_PREVIEW_KEYS.has(field.key))
|
||||
.filter((field) => !RESERVED_PREVIEW_KEYS.has(field.name))
|
||||
.map((field) => {
|
||||
const value = getSpanAttribute(
|
||||
{ resource: span.resource, attributes: span.attributes },
|
||||
field.key,
|
||||
field.name,
|
||||
);
|
||||
return value !== undefined && value !== ''
|
||||
? { key: field.key, value: String(value) }
|
||||
? { key: field.name, value: String(value) }
|
||||
: null;
|
||||
})
|
||||
.filter((r): r is SpanPreviewRowData => r !== null),
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import AddedFields from './AddedFields';
|
||||
import OtherFields from './OtherFields';
|
||||
|
||||
import styles from './FieldsSettings.module.scss';
|
||||
|
||||
const MAX_FIELDS_DEFAULT = 10;
|
||||
|
||||
interface FieldsSettingsProps {
|
||||
title: string;
|
||||
// Picker's native shape (`BaseAutocompleteData`) is preserved end-to-end so
|
||||
// downstream consumers (flamegraph `selectFields`, hover popovers) get full
|
||||
// field metadata without a lossy conversion at add-time.
|
||||
fields: BaseAutocompleteData[];
|
||||
onFieldsChange: (fields: BaseAutocompleteData[]) => void;
|
||||
onClose: () => void;
|
||||
dataSource: DataSource;
|
||||
maxFields?: number;
|
||||
}
|
||||
|
||||
function FieldsSettings({
|
||||
title,
|
||||
fields,
|
||||
onFieldsChange,
|
||||
onClose,
|
||||
dataSource,
|
||||
maxFields = MAX_FIELDS_DEFAULT,
|
||||
}: FieldsSettingsProps): JSX.Element {
|
||||
// Local draft state — changes here don't persist until Save
|
||||
const [draftFields, setDraftFields] = useState<BaseAutocompleteData[]>(fields);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [debouncedInputValue, setDebouncedInputValue] = useState('');
|
||||
|
||||
const debouncedUpdate = useDebouncedFn((value) => {
|
||||
setDebouncedInputValue(value as string);
|
||||
}, 400);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const value = e.target.value.trim().toLowerCase();
|
||||
setInputValue(value);
|
||||
debouncedUpdate(value);
|
||||
},
|
||||
[debouncedUpdate],
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(field: BaseAutocompleteData): void => {
|
||||
if (draftFields.length >= maxFields) {
|
||||
return;
|
||||
}
|
||||
if (draftFields.some((f) => f.key === field.key)) {
|
||||
return;
|
||||
}
|
||||
setDraftFields((prev) => [...prev, field]);
|
||||
},
|
||||
[draftFields, maxFields],
|
||||
);
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
onFieldsChange(draftFields);
|
||||
toast.success('Saved successfully', {
|
||||
position: 'top-right',
|
||||
});
|
||||
onClose();
|
||||
}, [draftFields, onFieldsChange, onClose]);
|
||||
|
||||
const handleDiscard = useCallback((): void => {
|
||||
setDraftFields(fields);
|
||||
}, [fields]);
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() =>
|
||||
!(
|
||||
draftFields.length === fields.length &&
|
||||
draftFields.every((f, i) => f.key === fields[i]?.key)
|
||||
),
|
||||
[draftFields, fields],
|
||||
);
|
||||
|
||||
const isAtLimit = draftFields.length >= maxFields;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
<TableColumnsSplit size={16} />
|
||||
{title}
|
||||
</div>
|
||||
<X className={styles.closeIcon} size={16} onClick={onClose} />
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<Input
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
placeholder="Search for a field..."
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<AddedFields
|
||||
inputValue={inputValue}
|
||||
fields={draftFields}
|
||||
onFieldsChange={setDraftFields}
|
||||
/>
|
||||
|
||||
<OtherFields
|
||||
dataSource={dataSource}
|
||||
debouncedInputValue={debouncedInputValue}
|
||||
addedFields={draftFields}
|
||||
onAdd={handleAdd}
|
||||
isAtLimit={isAtLimit}
|
||||
/>
|
||||
|
||||
{hasUnsavedChanges && (
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleDiscard}
|
||||
prefix={<X width={14} height={14} />}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSave}
|
||||
prefix={<Check width={14} height={14} />}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldsSettings;
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
AGGREGATIONS,
|
||||
getAggregationMap as findAggregationMap,
|
||||
} from '../utils/aggregations';
|
||||
import { toTelemetryFieldKey } from '../utils/previewFields';
|
||||
|
||||
interface MutateOptions {
|
||||
onSuccess?: () => void;
|
||||
@@ -37,7 +38,7 @@ interface TraceStoreState {
|
||||
// --- Derived state (cached for reference stability) ---
|
||||
colorByField: TelemetryFieldKey;
|
||||
availableColorByOptions: ColorByOption[];
|
||||
previewFields: BaseAutocompleteData[];
|
||||
previewFields: TelemetryFieldKey[];
|
||||
|
||||
// --- Setters used only by TraceStoreSync ---
|
||||
setAggregations: (
|
||||
@@ -51,7 +52,7 @@ interface TraceStoreState {
|
||||
|
||||
// --- Public actions (called from components) ---
|
||||
setColorByField: (field: TelemetryFieldKey) => void;
|
||||
setPreviewFields: (next: BaseAutocompleteData[]) => void;
|
||||
setPreviewFields: (next: TelemetryFieldKey[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,21 +106,31 @@ function deriveColorState(
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads preview fields from user preferences and filters out malformed entries.
|
||||
* Reads preview fields from user preferences and normalizes them to
|
||||
* `TelemetryFieldKey`. Legacy entries persisted as `BaseAutocompleteData` (with
|
||||
* a `.key` instead of `.name`) are upgraded in-place so existing users don't
|
||||
* lose their saved preview-field selection.
|
||||
*/
|
||||
function derivePreviewFields(
|
||||
userPreferences: UserPreference[] | null,
|
||||
): BaseAutocompleteData[] {
|
||||
): TelemetryFieldKey[] {
|
||||
const pref = userPreferences?.find(
|
||||
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PREVIEW_ATTRIBUTES,
|
||||
);
|
||||
const raw = (pref?.value as BaseAutocompleteData[] | undefined) ?? [];
|
||||
return raw.filter(
|
||||
(f): f is BaseAutocompleteData =>
|
||||
typeof f === 'object' &&
|
||||
f !== null &&
|
||||
typeof (f as { key?: unknown }).key === 'string',
|
||||
);
|
||||
const raw = (pref?.value as unknown[] | undefined) ?? [];
|
||||
const result: TelemetryFieldKey[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry !== 'object' || entry === null) {
|
||||
continue;
|
||||
}
|
||||
const candidate = entry as { name?: unknown; key?: unknown };
|
||||
if (typeof candidate.name === 'string') {
|
||||
result.push(entry as TelemetryFieldKey);
|
||||
} else if (typeof candidate.key === 'string') {
|
||||
result.push(toTelemetryFieldKey(entry as BaseAutocompleteData));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const useTraceStore = create<TraceStoreState>()((set, get) => ({
|
||||
|
||||
@@ -41,8 +41,9 @@ function mapFieldDataType(
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a picker-shaped field to the API's `TelemetryFieldKey` shape used
|
||||
* for `selectFields` on the flamegraph request.
|
||||
* Upgrades a legacy `BaseAutocompleteData`-shaped preview field (persisted by
|
||||
* pre-migration clients) to the current `TelemetryFieldKey` shape. Kept around
|
||||
* for the read-side compatibility shim in `traceStore.derivePreviewFields`.
|
||||
*/
|
||||
export function toTelemetryFieldKey(
|
||||
field: BaseAutocompleteData,
|
||||
@@ -51,6 +52,7 @@ export function toTelemetryFieldKey(
|
||||
name: field.key,
|
||||
fieldContext: mapFieldContext(field.type),
|
||||
fieldDataType: mapFieldDataType(field.dataType),
|
||||
isIndexed: field.isIndexed,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user