Compare commits

..

4 Commits

Author SHA1 Message Date
SagarRajput-7
ce1850a6b7 Merge branch 'main' into members-page 2026-03-03 01:35:03 +05:30
SagarRajput-7
02abe9cebc feat: added password modal and other functionality 2026-03-02 17:40:07 +05:30
SagarRajput-7
35443b3bd3 feat: added components and styles 2026-02-27 22:03:52 +05:30
SagarRajput-7
b797464995 feat: added members page and listing and edit view 2026-02-27 21:58:29 +05:30
100 changed files with 3016 additions and 2375 deletions

View File

@@ -6167,10 +6167,6 @@ paths:
name: searchText
schema:
type: string
- in: query
name: source
schema:
type: string
responses:
"200":
content:

View File

@@ -53,6 +53,8 @@
"@signozhq/combobox": "0.0.2",
"@signozhq/command": "0.0.0",
"@signozhq/design-tokens": "2.1.1",
"@signozhq/dialog": "0.0.2",
"@signozhq/drawer": "0.0.4",
"@signozhq/icons": "0.1.0",
"@signozhq/input": "0.0.2",
"@signozhq/popover": "0.0.0",
@@ -290,4 +292,4 @@
"on-headers": "^1.1.0",
"tmp": "0.2.4"
}
}
}

View File

@@ -13,5 +13,6 @@
"pipelines": "Pipelines",
"archives": "Archives",
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles"
"roles": "Roles",
"members": "Members"
}

View File

@@ -13,5 +13,6 @@
"pipelines": "Pipelines",
"archives": "Archives",
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles"
"roles": "Roles",
"members": "Members"
}

View File

@@ -74,5 +74,6 @@
"METER_EXPLORER": "SigNoz | Meter Explorer",
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles"
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members"
}

View File

@@ -3451,11 +3451,6 @@ export type ListMetricsParams = {
* @description undefined
*/
searchText?: string;
/**
* @type string
* @description undefined
*/
source?: string;
};
export type ListMetrics200 = {

View File

@@ -18,6 +18,8 @@ import '@signozhq/checkbox';
import '@signozhq/combobox';
import '@signozhq/command';
import '@signozhq/design-tokens';
import '@signozhq/dialog';
import '@signozhq/drawer';
import '@signozhq/icons';
import '@signozhq/input';
import '@signozhq/popover';

View File

@@ -0,0 +1,324 @@
.edit-member-drawer {
&__layout {
display: flex;
flex-direction: column;
height: calc(100vh - 48px);
}
&__body {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 16px;
}
&__field {
display: flex;
flex-direction: column;
gap: 8px;
}
&__label {
font-size: 14px;
font-weight: 400;
color: var(--vanilla-400, #c0c1c3);
line-height: 20px;
letter-spacing: -0.07px;
cursor: default;
}
&__input {
height: 32px;
width: 100%;
background: var(--bg-ink-300, #16181d) !important;
border-color: var(--slate-400, #1d212d) !important;
color: var(--vanilla-100, #fff) !important;
border-radius: 2px;
&::placeholder {
color: var(--vanilla-400, #c0c1c3);
}
}
&__input-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 8px;
border-radius: 2px;
background: var(--bg-ink-300, #16181d);
border: 1px solid var(--slate-400, #1d212d);
&--disabled {
cursor: not-allowed;
opacity: 0.8;
}
}
&__email-text {
font-size: 14px;
font-weight: 400;
color: var(--vanilla-400, #c0c1c3);
line-height: 18px;
letter-spacing: -0.07px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
&__lock-icon {
color: var(--vanilla-400, #c0c1c3);
flex-shrink: 0;
margin-left: 6px;
opacity: 0.6;
}
&__role-select {
width: 100%;
height: 32px;
.ant-select-selector {
height: 32px !important;
background: var(--bg-ink-300, #16181d) !important;
border-color: var(--slate-400, #1d212d) !important;
border-radius: 2px !important;
padding: 0 8px !important;
display: flex;
align-items: center;
}
.ant-select-selection-item {
font-size: 14px;
color: var(--vanilla-100, #fff);
line-height: 32px !important;
letter-spacing: -0.07px;
}
.ant-select-arrow {
color: var(--vanilla-400, #c0c1c3);
}
&:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--vanilla-400, #c0c1c3) !important;
}
}
&__meta {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 4px;
}
&__meta-item {
display: flex;
flex-direction: column;
gap: 4px;
}
&__meta-label {
font-family: Inter, sans-serif;
font-size: 12px;
font-weight: 500;
color: var(--vanilla-400, #c0c1c3);
line-height: 20px;
letter-spacing: 0.48px;
text-transform: uppercase;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 56px;
padding: 0 16px;
border-top: 1px solid var(--slate-400, #1d212d);
flex-shrink: 0;
background: var(--bg-ink-400, #121317);
}
&__footer-left {
display: flex;
align-items: center;
gap: 16px;
}
&__footer-right {
display: flex;
align-items: center;
gap: 12px;
}
&__footer-divider {
width: 1px;
height: 21px;
background: var(--slate-400, #1d212d);
flex-shrink: 0;
}
&__footer-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
font-family: Inter, sans-serif;
font-size: 11px;
font-weight: 400;
line-height: 1;
letter-spacing: 0;
transition: opacity 0.15s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:not(:disabled):hover {
opacity: 0.8;
}
&--danger {
color: var(--bg-cherry-500, #e5484d);
}
&--warning {
color: var(--bg-amber-500, #ffcd56);
}
}
&__delete-body {
font-size: 13px;
font-weight: 400;
color: var(--vanilla-400, #b0b2b4);
line-height: 20px;
letter-spacing: -0.065px;
margin: 0;
strong {
font-weight: 500;
color: var(--vanilla-100, #fff);
}
}
&__delete-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
}
}
.reset-link-dialog {
[data-slot='dialog-description'] {
width: 510px;
}
&__content {
display: flex;
flex-direction: column;
gap: 16px;
}
&__description {
font-size: 13px;
font-weight: 400;
color: var(--vanilla-400, #b0b2b4);
line-height: 20px;
letter-spacing: -0.065px;
margin: 0;
white-space: normal;
word-break: break-word;
}
&__link-row {
display: flex;
align-items: center;
height: 32px;
overflow: hidden;
background: var(--bg-ink-300, #16181d);
border: 1px solid var(--slate-400, #1d212d);
border-radius: 2px;
}
&__link-text-wrap {
flex: 1;
min-width: 0;
overflow: hidden;
}
&__link-text {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 8px;
font-size: 14px;
font-weight: 400;
color: var(--vanilla-400, #c0c1c3);
line-height: 18px;
letter-spacing: -0.07px;
}
&__copy-btn {
flex-shrink: 0;
height: 32px !important;
border-radius: 0 2px 2px 0 !important;
border-top: none !important;
border-right: none !important;
border-bottom: none !important;
border-left: 1px solid var(--slate-400, #1d212d) !important;
min-width: 64px;
}
}
.lightMode {
.edit-member-drawer {
&__label,
&__email-text,
&__meta-label {
color: #5a5f6e;
}
&__input {
background: #f5f5f7 !important;
border-color: #d9d9d9 !important;
color: #1d1f29 !important;
}
&__input-wrapper {
background: #f5f5f7;
border-color: #d9d9d9;
}
&__role-select {
.ant-select-selector {
background: #f5f5f7 !important;
border-color: #d9d9d9 !important;
}
.ant-select-selection-item {
color: #1d1f29;
}
}
&__footer-divider {
background: #e8e8e8;
}
&__delete-body {
color: #5a5f6e;
strong {
color: #1d1f29;
}
}
}
}

View File

@@ -0,0 +1,471 @@
import { useCallback, useEffect, useState } from 'react';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { DrawerWrapper } from '@signozhq/drawer';
import {
Check,
ChevronDown,
Copy,
Link,
LockKeyhole,
RefreshCw,
Trash2,
X,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { Select } from 'antd';
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
import sendInvite from 'api/v1/invite/create';
import cancelInvite from 'api/v1/invite/id/delete';
import deleteUser from 'api/v1/user/id/delete';
import update from 'api/v1/user/id/update';
import { MemberRow } from 'components/MembersTable/MembersTable';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { useTimezone } from 'providers/Timezone';
import { ROLES } from 'types/roles';
import './EditMemberDrawer.styles.scss';
export interface EditMemberDrawerProps {
member: MemberRow | null;
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
const INVITE_PREFIX = 'invite-';
function formatRoleLabel(role: string): string {
return role.charAt(0).toUpperCase() + role.slice(1).toLowerCase();
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function EditMemberDrawer({
member,
open,
onClose,
onSuccess,
}: EditMemberDrawerProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const [displayName, setDisplayName] = useState('');
const [selectedRole, setSelectedRole] = useState<ROLES>('VIEWER');
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [resetLink, setResetLink] = useState<string | null>(null);
const [showResetLinkDialog, setShowResetLinkDialog] = useState(false);
const [hasCopiedResetLink, setHasCopiedResetLink] = useState(false);
const isInvited = member?.status === 'Invited';
// Invited member IDs are prefixed with 'invite-'; strip it to get the real invite ID
const inviteId =
isInvited && member ? member.id.slice(INVITE_PREFIX.length) : null;
useEffect(() => {
if (member) {
setDisplayName(member.name);
setSelectedRole(member.role);
}
}, [member]);
const isDirty =
member !== null &&
(displayName !== member.name || selectedRole !== member.role);
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
if (!ts) {
return '—';
}
const d = new Date(ts);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
},
[formatTimezoneAdjustedTimestamp],
);
const handleSave = useCallback(async (): Promise<void> => {
if (!member || !isDirty) {
return;
}
setIsSaving(true);
try {
if (isInvited && inviteId) {
await cancelInvite({ id: inviteId });
await sendInvite({
email: member.email,
name: displayName,
role: selectedRole,
frontendBaseUrl: window.location.origin,
});
toast.success('Invite updated successfully', { richColors: true });
} else {
await update({
userId: member.id,
displayName,
role: selectedRole,
});
toast.success('Member details updated successfully', { richColors: true });
}
onSuccess();
onClose();
} catch {
toast.error(
isInvited ? 'Failed to update invite' : 'Failed to update member details',
{ richColors: true },
);
} finally {
setIsSaving(false);
}
}, [
member,
isDirty,
isInvited,
inviteId,
displayName,
selectedRole,
onSuccess,
onClose,
]);
const handleDelete = useCallback(async (): Promise<void> => {
if (!member) {
return;
}
setIsDeleting(true);
try {
if (isInvited && inviteId) {
await cancelInvite({ id: inviteId });
toast.success('Invitation cancelled successfully', { richColors: true });
} else {
await deleteUser({ userId: member.id });
toast.success('Member deleted successfully', { richColors: true });
}
setShowDeleteConfirm(false);
onSuccess();
onClose();
} catch {
toast.error(
isInvited ? 'Failed to cancel invitation' : 'Failed to delete member',
{ richColors: true },
);
} finally {
setIsDeleting(false);
}
}, [member, isInvited, inviteId, onSuccess, onClose]);
const handleGenerateResetLink = useCallback(async (): Promise<void> => {
if (!member) {
return;
}
setIsGeneratingLink(true);
try {
const response = await getResetPasswordToken({ userId: member.id });
if (response?.data?.token) {
const link = `${window.location.origin}/password-reset?token=${response.data.token}`;
setResetLink(link);
setHasCopiedResetLink(false);
setShowResetLinkDialog(true);
onClose();
}
} catch {
toast.error('Failed to generate password reset link', {
richColors: true,
position: 'top-right',
});
} finally {
setIsGeneratingLink(false);
}
}, [member, onClose]);
const handleCopyResetLink = useCallback(async (): Promise<void> => {
if (!resetLink) {
return;
}
try {
await navigator.clipboard.writeText(resetLink);
setHasCopiedResetLink(true);
setTimeout(() => setHasCopiedResetLink(false), 2000);
toast.success('Reset link copied to clipboard', { richColors: true });
} catch {
toast.error('Failed to copy link', {
richColors: true,
});
}
}, [resetLink]);
const handleCopyInviteLink = useCallback(async (): Promise<void> => {
if (!member?.token) {
toast.error('Invite link is not available', {
richColors: true,
position: 'top-right',
});
return;
}
const inviteLink = `${window.location.origin}${ROUTES.SIGN_UP}?token=${member.token}`;
try {
await navigator.clipboard.writeText(inviteLink);
toast.success('Invite link copied to clipboard', {
richColors: true,
position: 'top-right',
});
} catch {
toast.error('Failed to copy invite link', {
richColors: true,
position: 'top-right',
});
}
}, [member]);
const handleClose = useCallback((): void => {
setShowDeleteConfirm(false);
onClose();
}, [onClose]);
const joinedOnLabel = isInvited ? 'Invited On' : 'Joined On';
const drawerContent = (
<div className="edit-member-drawer__layout">
<div className="edit-member-drawer__body">
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-name">
Name
</label>
<Input
id="member-name"
value={displayName}
onChange={(e): void => setDisplayName(e.target.value)}
className="edit-member-drawer__input"
placeholder="Enter name"
/>
</div>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-email">
Email Address
</label>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<span className="edit-member-drawer__email-text">
{member?.email || '—'}
</span>
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
</div>
</div>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-role">
Roles
</label>
<Select
id="member-role"
value={selectedRole}
onChange={(role): void => setSelectedRole(role as ROLES)}
className="edit-member-drawer__role-select"
suffixIcon={<ChevronDown size={14} />}
getPopupContainer={(triggerNode): HTMLElement =>
(triggerNode?.closest('.edit-member-drawer') as HTMLElement) ||
document.body
}
>
<Select.Option value="ADMIN">{formatRoleLabel('ADMIN')}</Select.Option>
<Select.Option value="EDITOR">{formatRoleLabel('EDITOR')}</Select.Option>
<Select.Option value="VIEWER">{formatRoleLabel('VIEWER')}</Select.Option>
</Select>
</div>
<div className="edit-member-drawer__meta">
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Status</span>
{member?.status === 'Active' ? (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : (
<Badge color="amber" variant="outline">
INVITED
</Badge>
)}
</div>
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
</div>
{!isInvited && (
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Last Modified</span>
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
</div>
)}
</div>
</div>
<div className="edit-member-drawer__footer">
<div className="edit-member-drawer__footer-left">
<button
type="button"
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
onClick={(): void => setShowDeleteConfirm(true)}
>
<Trash2 size={12} />
{isInvited ? 'Cancel Invite' : 'Delete Member'}
</button>
<div className="edit-member-drawer__footer-divider" />
{isInvited ? (
<button
type="button"
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={handleCopyInviteLink}
disabled={!member?.token}
>
<Link size={12} />
Copy Invite Link
</button>
) : (
<button
type="button"
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={handleGenerateResetLink}
disabled={isGeneratingLink}
>
<RefreshCw size={12} />
{isGeneratingLink ? 'Generating...' : 'Generate Password Reset Link'}
</button>
)}
</div>
<div className="edit-member-drawer__footer-right">
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
</div>
</div>
</div>
);
const deleteDialogTitle = isInvited ? 'Cancel Invitation' : 'Delete Member';
const deleteDialogBody = isInvited
? `Are you sure you want to cancel the invitation for ${member?.email}? They will no longer be able to join the workspace using this invite.`
: `Are you sure you want to delete ${
member?.name || member?.email
}? This will permanently remove their access to the workspace.`;
const deleteConfirmLabel = isInvited ? 'Cancel Invite' : 'Delete Member';
return (
<>
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
direction="right"
type="panel"
showCloseButton
showOverlay={false}
allowOutsideClick
header={{ title: 'Member Details' }}
content={drawerContent}
className="edit-member-drawer"
/>
<DialogWrapper
open={showResetLinkDialog}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setShowResetLinkDialog(false);
}
}}
title="Password Reset Link"
showCloseButton
width="base"
className="reset-link-dialog"
>
<div className="reset-link-dialog__content">
<p className="reset-link-dialog__description">
This creates a one-time link the team member can use to set a new password
for their SigNoz account.
</p>
<div className="reset-link-dialog__link-row">
<div className="reset-link-dialog__link-text-wrap">
<span className="reset-link-dialog__link-text">{resetLink}</span>
</div>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={handleCopyResetLink}
prefixIcon={
hasCopiedResetLink ? <Check size={12} /> : <Copy size={12} />
}
className="reset-link-dialog__copy-btn"
>
{hasCopiedResetLink ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
</DialogWrapper>
<DialogWrapper
open={showDeleteConfirm}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setShowDeleteConfirm(false);
}
}}
title={deleteDialogTitle}
width="narrow"
className="alert-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="edit-member-drawer__delete-body">{deleteDialogBody}</p>
<DialogFooter className="edit-member-drawer__delete-footer">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setShowDeleteConfirm(false)}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isDeleting}
onClick={handleDelete}
>
<Trash2 size={12} />
{isDeleting ? 'Processing...' : deleteConfirmLabel}
</Button>
</DialogFooter>
</DialogWrapper>
</>
);
}
export default EditMemberDrawer;

View File

@@ -0,0 +1,420 @@
.invite-members-modal {
display: flex !important;
flex-direction: column !important;
max-width: 700px !important;
padding: 0 !important;
gap: 0 !important;
background: #121317 !important;
border: 1px solid #161922 !important;
border-radius: 4px !important;
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04) !important;
[data-slot='dialog-header'] {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #161922;
margin: 0;
flex-shrink: 0;
background: transparent;
}
[data-slot='dialog-title'] {
font-family: Inter, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.065px;
color: #ffffff;
margin: 0;
}
// Close button rendered by DialogWrapper
[data-slot='dialog-close'] {
color: #c0c1c3;
opacity: 0.7;
transition: opacity 0.15s;
background: transparent;
border: none;
&:hover {
opacity: 1;
}
}
}
// ─── Content area ─────────────────────────────────────────────────────────────
.invite-members-modal__content {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
flex: 1;
}
// ─── Table Structure ──────────────────────────────────────────────────────────
.invite-members-modal__table {
width: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.invite-members-modal__table-header {
display: flex;
gap: 16px;
align-items: center;
flex-shrink: 0;
height: auto;
width: 100%;
justify-content: flex-end;
.email-header {
flex: 0 0 220px;
width: 220px;
}
.role-header {
flex: 1 0 0;
min-width: 0;
}
.action-header {
flex: 0 0 32px;
width: 32px;
}
.table-header-cell {
color: var(--l1-foreground);
font-family: Inter, sans-serif;
font-size: 13px;
font-style: normal;
font-weight: 600;
line-height: 100%;
letter-spacing: -0.065px;
}
}
.invite-members-modal__container {
display: flex;
flex-direction: column;
gap: 16px !important;
width: 100%;
flex: 0 1 auto;
min-height: 0;
overflow-x: hidden;
}
// ─── Rows & Cells ─────────────────────────────────────────────────────────────
.team-member-row {
display: flex;
width: 100%;
gap: 16px;
> div.email-cell {
flex: 0 0 220px;
width: 220px;
}
> div.role-cell {
flex: 1 0 0;
min-width: 0;
}
> div.action-cell {
flex: 0 0 32px;
width: 32px;
}
}
.team-member-cell {
display: flex;
flex-direction: column;
gap: 8px;
&.action-cell {
display: flex;
align-items: flex-end;
justify-content: center;
height: 32px;
}
}
// ─── Form Elements ────────────────────────────────────────────────────────────
.team-member-email-input {
width: 100%;
height: 32px !important;
border-radius: 2px;
background: var(--l3-background);
border: 1px solid var(--l3-border);
color: var(--l1-foreground);
font-family: Inter, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.065px;
padding: 6px 8px;
box-sizing: border-box;
&::placeholder {
color: var(--l3-foreground);
}
&:hover {
border-color: var(--l3-border);
}
&:focus {
border-color: var(--bg-robin-500);
box-shadow: none;
}
}
.team-member-role-select {
width: 100%;
.ant-select-selector {
height: 32px !important;
border-radius: 2px !important;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
color: var(--l1-foreground) !important;
font-family: Inter, sans-serif !important;
font-size: 13px !important;
font-weight: 400 !important;
line-height: 1 !important;
letter-spacing: -0.065px !important;
padding: 0 8px !important;
box-sizing: border-box !important;
.ant-select-selection-placeholder {
color: var(--l3-foreground) !important;
}
.ant-select-selection-item {
color: var(--l1-foreground) !important;
line-height: 30px !important;
}
}
.ant-select-arrow {
color: var(--l3-foreground) !important;
}
&.ant-select-focused .ant-select-selector {
border-color: var(--bg-robin-500) !important;
}
&:hover .ant-select-selector {
border-color: var(--bg-robin-500) !important;
}
}
// ─── Actions ──────────────────────────────────────────────────────────────────
.remove-team-member-button {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
border: none !important;
border-radius: 2px !important;
background: transparent !important;
color: #e5484d !important;
opacity: 0.6 !important;
cursor: pointer;
padding: 0 !important;
transition: background-color 0.2s, opacity 0.2s;
box-shadow: none !important;
svg {
color: #e5484d !important;
width: 14px !important;
height: 14px !important;
}
&:hover {
background: rgba(229, 72, 77, 0.1) !important;
opacity: 0.9 !important;
color: #e5484d !important;
svg {
color: #e5484d !important;
}
}
&:active {
opacity: 0.7 !important;
background: rgba(229, 72, 77, 0.15) !important;
}
&:focus-visible {
outline: 2px solid var(--bg-robin-500);
outline-offset: 2px;
}
}
// ─── Validation ───────────────────────────────────────────────────────────────
.email-error-message {
font-size: 12px;
font-weight: 400;
line-height: 16px;
margin-top: 4px;
width: 100%;
display: block;
color: var(--bg-cherry-500);
}
.invite-team-members-error-callout {
background: rgba(229, 72, 77, 0.1);
border: 1px solid rgba(229, 72, 77, 0.2);
border-radius: 4px;
animation: horizontal-shaking 300ms ease-out;
}
@keyframes horizontal-shaking {
0% {
transform: translateX(0);
}
25% {
transform: translateX(5px);
}
50% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0);
}
}
// ─── Footer ───────────────────────────────────────────────────────────────────
.invite-members-modal__footer {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: space-between !important;
padding: 0 16px !important;
height: 64px !important;
min-height: 64px !important;
border-top: 1px solid #161922 !important;
gap: 0 !important;
flex-shrink: 0;
}
.invite-members-modal__footer-right {
display: flex;
align-items: center;
gap: 12px;
}
.add-another-member-button {
&:hover {
border-color: var(--bg-robin-500) !important;
border-style: dashed !important;
color: var(--l1-foreground);
svg,
[class*='icon'] {
color: var(--l1-foreground) !important;
}
}
}
// ─── Light mode overrides ─────────────────────────────────────────────────────
.lightMode {
.invite-members-modal {
background: #ffffff !important;
border-color: #e9e9e9 !important;
[data-slot='dialog-header'] {
border-bottom-color: #e9e9e9;
}
[data-slot='dialog-title'] {
color: #121317;
}
}
.invite-members-modal__table-header {
.table-header-cell {
color: var(--l3-foreground);
}
}
.team-member-email-input {
background: var(--bg-vanilla-200) !important;
border-color: var(--bg-vanilla-300) !important;
color: var(--text-ink-500) !important;
&::placeholder {
color: var(--text-neutral-light-200) !important;
}
&:hover {
border-color: var(--bg-vanilla-300) !important;
}
&:focus {
border-color: var(--bg-robin-500) !important;
}
}
.team-member-role-select {
.ant-select-selector {
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
color: var(--l1-foreground) !important;
.ant-select-selection-placeholder {
color: var(--l3-foreground) !important;
}
.ant-select-selection-item {
color: var(--l1-foreground) !important;
}
}
.ant-select-arrow {
color: var(--l3-foreground) !important;
}
}
.remove-team-member-button {
color: var(--bg-cherry-500) !important;
svg {
color: var(--bg-cherry-500) !important;
}
&:hover {
background: rgba(245, 101, 101, 0.1) !important;
}
}
.add-another-member-button {
border-color: var(--text-vanilla-300) !important;
color: var(--l3-foreground);
svg {
color: var(--l3-foreground) !important;
}
&:hover {
border-color: var(--bg-robin-500) !important;
color: var(--l1-foreground);
background: rgba(78, 116, 248, 0.1) !important;
svg {
color: var(--l1-foreground) !important;
}
}
}
.invite-members-modal__footer {
border-top-color: #e9e9e9 !important;
}
}

View File

@@ -0,0 +1,335 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from '@signozhq/button';
import { Callout } from '@signozhq/callout';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { Select, Typography } from 'antd';
import sendInvite from 'api/v1/invite/create';
import { cloneDeep, debounce } from 'lodash-es';
import { ChevronDown, CircleAlert, Plus, Trash2, X } from 'lucide-react';
import { ROLES } from 'types/roles';
import { v4 as uuid } from 'uuid';
import './InviteMembersModal.styles.scss';
interface InviteRow {
id: string;
email: string;
role: ROLES | '';
}
export interface InviteMembersModalProps {
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
const EMPTY_ROW = (): InviteRow => ({ id: uuid(), email: '', role: '' });
const isRowTouched = (row: InviteRow): boolean =>
row.email.trim() !== '' || Boolean(row.role && row.role.trim() !== '');
function InviteMembersModal({
open,
onClose,
onSuccess,
}: InviteMembersModalProps): JSX.Element {
const [rows, setRows] = useState<InviteRow[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
{},
);
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
const [hasInvalidRoles, setHasInvalidRoles] = useState<boolean>(false);
const resetAndClose = useCallback((): void => {
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
setEmailValidity({});
setHasInvalidEmails(false);
setHasInvalidRoles(false);
onClose();
}, [onClose]);
useEffect(() => {
if (open) {
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
}
}, [open]);
const getValidationErrorMessage = (): string => {
if (hasInvalidEmails && hasInvalidRoles) {
return 'Please enter valid emails and select roles for team members';
}
if (hasInvalidEmails) {
return 'Please enter valid emails for team members';
}
return 'Please select roles for team members';
};
const validateAllUsers = useCallback((): boolean => {
let isValid = true;
let hasEmailErrors = false;
let hasRoleErrors = false;
const updatedEmailValidity: Record<string, boolean> = {};
const touchedRows = rows.filter(isRowTouched);
touchedRows.forEach((row) => {
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(row.email);
const roleValid = Boolean(row.role && row.role.trim() !== '');
if (!emailValid || !row.email) {
isValid = false;
hasEmailErrors = true;
}
if (!roleValid) {
isValid = false;
hasRoleErrors = true;
}
if (row.id) {
updatedEmailValidity[row.id] = emailValid;
}
});
setEmailValidity(updatedEmailValidity);
setHasInvalidEmails(hasEmailErrors);
setHasInvalidRoles(hasRoleErrors);
return isValid;
}, [rows]);
const debouncedValidateEmail = useMemo(
() =>
debounce((email: string, rowId: string) => {
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
}, 500),
[],
);
const updateEmail = (id: string, email: string): void => {
const updatedRows = cloneDeep(rows);
const rowToUpdate = updatedRows.find((r) => r.id === id);
if (rowToUpdate) {
rowToUpdate.email = email;
setRows(updatedRows);
if (hasInvalidEmails) {
setHasInvalidEmails(false);
}
if (emailValidity[id] === false) {
setEmailValidity((prev) => ({ ...prev, [id]: true }));
}
debouncedValidateEmail(email, id);
}
};
const updateRole = (id: string, role: ROLES): void => {
const updatedRows = cloneDeep(rows);
const rowToUpdate = updatedRows.find((r) => r.id === id);
if (rowToUpdate) {
rowToUpdate.role = role;
setRows(updatedRows);
if (hasInvalidRoles) {
setHasInvalidRoles(false);
}
}
};
const addRow = (): void => {
setRows((prev) => [...prev, EMPTY_ROW()]);
};
const removeRow = (id: string): void => {
setRows((prev) => prev.filter((r) => r.id !== id));
};
const handleSubmit = useCallback(async (): Promise<void> => {
if (!validateAllUsers()) {
return;
}
const touchedRows = rows.filter(isRowTouched);
if (touchedRows.length === 0) {
return;
}
setIsSubmitting(true);
let hasError = false;
await Promise.all(
touchedRows.map(
async (row): Promise<void> => {
try {
await sendInvite({
email: row.email.trim(),
name: '',
role: row.role as ROLES,
frontendBaseUrl: window.location.origin,
});
} catch (err: unknown) {
hasError = true;
const apiErr = err as {
response?: { status: number };
getErrorMessage?: () => string;
};
if (apiErr?.response?.status === 409) {
toast.error(`${row.email} is already a member`, { richColors: true });
} else {
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
toast.error(`Failed to invite ${row.email}: ${errorMessage}`, {
richColors: true,
});
}
}
},
),
);
setIsSubmitting(false);
if (!hasError) {
toast.success('Invites sent successfully', { richColors: true });
resetAndClose();
onSuccess?.();
} else {
onSuccess?.();
}
}, [rows, onSuccess, resetAndClose, validateAllUsers]);
const touchedRows = rows.filter(isRowTouched);
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
return (
<DialogWrapper
title="Invite Team Members"
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
resetAndClose();
}
}}
showCloseButton
width="wide"
className="invite-members-modal"
disableOutsideClick
>
<div className="invite-members-modal__content">
<div className="invite-members-modal__table">
<div className="invite-members-modal__table-header">
<div className="table-header-cell email-header">Email address</div>
<div className="table-header-cell role-header">Roles</div>
<div className="table-header-cell action-header" />
</div>
<div className="invite-members-modal__container">
{rows.map(
(row): JSX.Element => (
<div key={row.id} className="team-member-row">
<div className="team-member-cell email-cell">
<Input
type="email"
placeholder="john@signoz.io"
value={row.email}
onChange={(e): void => updateEmail(row.id, e.target.value)}
className="team-member-email-input"
/>
{emailValidity[row.id] === false && row.email.trim() !== '' && (
<Typography.Text className="email-error-message">
Invalid email address
</Typography.Text>
)}
</div>
<div className="team-member-cell role-cell">
<Select
value={row.role || undefined}
onChange={(role): void => updateRole(row.id, role as ROLES)}
className="team-member-role-select"
placeholder="Select roles"
suffixIcon={<ChevronDown size={14} />}
getPopupContainer={(triggerNode): HTMLElement =>
(triggerNode?.closest('.invite-members-modal') as HTMLElement) ||
document.body
}
>
<Select.Option value="VIEWER">Viewer</Select.Option>
<Select.Option value="EDITOR">Editor</Select.Option>
<Select.Option value="ADMIN">Admin</Select.Option>
</Select>
</div>
<div className="team-member-cell action-cell">
{rows.length > 1 && (
<Button
variant="ghost"
color="secondary"
className="remove-team-member-button"
onClick={(): void => removeRow(row.id)}
aria-label="Remove row"
>
<Trash2 size={12} />
</Button>
)}
</div>
</div>
),
)}
</div>
</div>
{(hasInvalidEmails || hasInvalidRoles) && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className="invite-team-members-error-callout"
description={getValidationErrorMessage()}
/>
)}
</div>
{/* Footer */}
<DialogFooter className="invite-members-modal__footer">
<Button
variant="dashed"
color="secondary"
size="sm"
className="add-another-member-button"
prefixIcon={<Plus size={12} />}
onClick={addRow}
>
Add another
</Button>
<div className="invite-members-modal__footer-right">
<Button
type="button"
variant="solid"
color="secondary"
size="sm"
onClick={resetAndClose}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
onClick={handleSubmit}
disabled={isSubmitDisabled}
>
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
</Button>
</div>
</DialogFooter>
</DialogWrapper>
);
}
export default InviteMembersModal;

View File

@@ -0,0 +1,250 @@
// ─── Table wrapper ────────────────────────────────────────────────────────────
.members-table-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: 4px;
}
// ─── Ant Design Table overrides ───────────────────────────────────────────────
.members-table {
// Flatten Ant Design chrome
.ant-table {
background: transparent;
font-size: 14px;
}
.ant-table-container {
border-radius: 0 !important;
border: none !important;
}
// ── Header ──────────────────────────────────────────────────────────────
.ant-table-thead {
> tr > th,
> tr > td {
background: var(--bg-ink-500, #0b0c0e);
font-family: Inter, sans-serif;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.44px;
text-transform: uppercase;
color: var(--vanilla-400, #c0c1c3);
line-height: 18px;
padding: 8px 16px;
border-bottom: none !important;
border-top: none !important;
// Remove the column divider pseudo-element
&::before {
display: none !important;
}
// Sorter icon color
.ant-table-column-sorter {
color: var(--vanilla-400, #c0c1c3);
opacity: 0.6;
}
.ant-table-column-sorter-up.active,
.ant-table-column-sorter-down.active {
color: var(--vanilla-100, #ffffff);
opacity: 1;
}
}
}
// ── Body rows ────────────────────────────────────────────────────────────
.ant-table-tbody {
> tr > td {
border-bottom: none !important;
padding: 8px 16px;
background: transparent;
transition: none;
}
// Even-indexed rows (0, 2, 4 …) get a subtle tint — matches Figma
> tr.members-table-row--tinted > td {
background: rgba(171, 189, 255, 0.02);
}
// Hover — slightly brighter tint
> tr:hover > td {
background: rgba(171, 189, 255, 0.04) !important;
}
}
// Remove outer table border
.ant-table-wrapper,
.ant-table-container,
.ant-spin-nested-loading,
.ant-spin-container {
border: none !important;
box-shadow: none !important;
}
// ── Status cell — right-align badge and add 4px right padding ─────────
.member-status-cell {
padding-right: 4px !important;
}
}
// ─── Name / Email cell ────────────────────────────────────────────────────────
.member-name-email-cell {
display: flex;
align-items: center;
gap: 4px;
height: 22px;
overflow: hidden;
.member-name {
font-size: 14px;
font-weight: 500;
color: var(--vanilla-400, #c0c1c3);
line-height: 20px;
letter-spacing: -0.07px;
white-space: nowrap;
flex-shrink: 0;
}
.member-email {
font-size: 14px;
font-weight: 400;
color: var(--slate-50, #62687c);
line-height: 20px;
letter-spacing: -0.07px;
flex: 1 0 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// ─── Joined On ────────────────────────────────────────────────────────────────
.member-joined-date {
font-size: 14px;
font-weight: 400;
color: var(--vanilla-400, #c0c1c3);
line-height: 18px;
letter-spacing: -0.07px;
white-space: nowrap;
}
.member-joined-dash {
font-size: 14px;
color: var(--slate-50, #62687c);
line-height: 18px;
letter-spacing: -0.07px;
}
// ─── Empty state ─────────────────────────────────────────────────────────────
.members-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 16px;
gap: 8px;
&__emoji {
font-size: 24px;
line-height: 1;
}
&__text {
font-size: 14px;
font-weight: 400;
color: var(--vanilla-400, #c0c1c3);
margin: 0;
line-height: 20px;
strong {
font-weight: 500;
color: var(--vanilla-100, #ffffff);
}
}
}
// ─── Pagination ───────────────────────────────────────────────────────────────
.members-table-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 8px 16px;
.ant-pagination-total-text {
margin-right: auto;
}
.members-pagination-range {
font-family: Inter, sans-serif;
font-size: 12px;
color: var(--vanilla-400, #c0c1c3);
}
.members-pagination-total {
font-family: Inter, sans-serif;
font-size: 12px;
color: var(--vanilla-400, #c0c1c3);
opacity: 0.5;
}
}
// ─── Light mode overrides ─────────────────────────────────────────────────────
.lightMode {
.members-table {
.ant-table-thead {
> tr > th,
> tr > td {
background: #f5f5f7;
color: #5a5f6e;
}
}
.ant-table-tbody {
> tr.members-table-row--tinted > td {
background: rgba(0, 0, 0, 0.015);
}
> tr:hover > td {
background: rgba(0, 0, 0, 0.03) !important;
}
}
}
.member-name-email-cell {
.member-name {
color: #1d1f29;
}
.member-email {
color: #5a5f6e;
}
}
.member-joined-dash {
color: #5a5f6e;
}
.member-joined-date {
color: #1d1f29;
}
.members-empty-state {
&__text {
color: #5a5f6e;
strong {
color: #1d1f29;
}
}
}
.members-pagination-range,
.members-pagination-total {
color: #5a5f6e;
}
}

View File

@@ -0,0 +1,232 @@
import type React from 'react';
import { Badge } from '@signozhq/badge';
import { Pagination, Table, Tooltip } from 'antd';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { useTimezone } from 'providers/Timezone';
import { ROLES } from 'types/roles';
import './MembersTable.styles.scss';
export interface MemberRow {
id: string;
name: string;
email: string;
role: ROLES;
status: 'Active' | 'Invited';
joinedOn: string | null;
updatedAt?: string | null;
token?: string | null;
}
interface MembersTableProps {
data: MemberRow[];
loading: boolean;
total: number;
currentPage: number;
pageSize: number;
searchQuery: string;
onPageChange: (page: number) => void;
onRowClick?: (member: MemberRow) => void;
onSortChange?: (
sorter: SorterResult<MemberRow> | SorterResult<MemberRow>[],
) => void;
}
function formatRoleLabel(role: string): string {
return role.charAt(0).toUpperCase() + role.slice(1).toLowerCase();
}
function NameEmailCell({
name,
email,
}: {
name: string;
email: string;
}): JSX.Element {
return (
<div className="member-name-email-cell">
{name && (
<span className="member-name" title={name}>
{name}
</span>
)}
<Tooltip title={email} overlayClassName="member-tooltip">
<span className="member-email">{email}</span>
</Tooltip>
</div>
);
}
function StatusBadge({ status }: { status: MemberRow['status'] }): JSX.Element {
if (status === 'Active') {
return (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
);
}
return (
<Badge color="amber" variant="outline">
INVITED
</Badge>
);
}
function MembersEmptyState({
searchQuery,
}: {
searchQuery: string;
}): JSX.Element {
return (
<div className="members-empty-state">
<span className="members-empty-state__emoji">🧐</span>
{searchQuery ? (
<p className="members-empty-state__text">
No results for <strong>{searchQuery}</strong>
</p>
) : (
<p className="members-empty-state__text">No members found</p>
)}
</div>
);
}
function MembersTable({
data,
loading,
total,
currentPage,
pageSize,
searchQuery,
onPageChange,
onRowClick,
onSortChange,
}: MembersTableProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const formatJoinedOn = (date: string | null): string => {
if (!date) {
return '—';
}
const d = new Date(date);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.DASH_DATETIME);
};
const columns: ColumnsType<MemberRow> = [
{
title: 'Name / Email',
dataIndex: 'name',
key: 'name',
render: (_, record): JSX.Element => (
<NameEmailCell name={record.name} email={record.email} />
),
},
{
title: 'Roles',
dataIndex: 'role',
key: 'role',
width: 180,
render: (role: ROLES): JSX.Element => (
<Badge color="vanilla">{formatRoleLabel(role)}</Badge>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 100,
align: 'right' as const,
className: 'member-status-cell',
sorter: (a, b): number => a.status.localeCompare(b.status),
render: (status: MemberRow['status']): JSX.Element => (
<StatusBadge status={status} />
),
},
{
title: 'Joined On',
dataIndex: 'joinedOn',
key: 'joinedOn',
width: 250,
align: 'right' as const,
sorter: (a, b): number => {
if (!a.joinedOn && !b.joinedOn) {
return 0;
}
if (!a.joinedOn) {
return 1;
}
if (!b.joinedOn) {
return -1;
}
return new Date(a.joinedOn).getTime() - new Date(b.joinedOn).getTime();
},
render: (joinedOn: string | null): JSX.Element => {
const formatted = formatJoinedOn(joinedOn);
const isDash = formatted === '—';
return (
<span className={isDash ? 'member-joined-dash' : 'member-joined-date'}>
{formatted}
</span>
);
},
},
];
const showPaginationTotal = (_total: number, range: number[]): JSX.Element => (
<>
<span className="members-pagination-range">
{range[0]} &#8212; {range[1]}
</span>
<span className="members-pagination-total"> of {_total}</span>
</>
);
return (
<div className="members-table-wrapper">
<Table<MemberRow>
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={false}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'members-table-row--tinted' : ''
}
onRow={(record): React.HTMLAttributes<HTMLElement> => ({
onClick: (): void => onRowClick?.(record),
style: onRowClick ? { cursor: 'pointer' } : undefined,
})}
onChange={(_, __, sorter): void => {
if (onSortChange) {
onSortChange(
sorter as SorterResult<MemberRow> | SorterResult<MemberRow>[],
);
}
}}
showSorterTooltip={false}
locale={{
emptyText: <MembersEmptyState searchQuery={searchQuery} />,
}}
className="members-table"
/>
{total > pageSize && (
<Pagination
current={currentPage}
pageSize={pageSize}
total={total}
showTotal={showPaginationTotal}
showSizeChanger={false}
onChange={onPageChange}
className="members-table-pagination"
/>
)}
</div>
);
}
export default MembersTable;

View File

@@ -60,30 +60,11 @@
gap: 8px;
margin-left: 108px;
position: relative;
/* Vertical dashed line connecting query elements */
&::after {
content: '';
position: absolute;
left: -28px;
top: 0;
bottom: 0;
width: 1px;
background: repeating-linear-gradient(
to bottom,
#1d212d,
#1d212d 4px,
transparent 4px,
transparent 8px
);
}
.code-mirror-where-clause,
.query-aggregation-container,
.query-add-ons,
.metrics-aggregation-section-content,
.metrics-container {
.metrics-aggregation-section-content {
position: relative;
&::before {
@@ -121,10 +102,6 @@
.qb-elements-container {
margin-left: 0px;
&::after {
display: none;
}
.code-mirror-where-clause,
.query-aggregation-container,
.query-add-ons,
@@ -356,7 +333,28 @@
text-transform: uppercase;
&::before {
display: none;
content: '';
height: 120px;
content: '';
position: absolute;
left: 0;
top: 31px;
bottom: 0;
width: 1px;
background: repeating-linear-gradient(
to bottom,
#1d212d,
#1d212d 4px,
transparent 4px,
transparent 8px
);
left: 15px;
}
&.has-trace-operator {
&::before {
height: 0px;
}
}
}
@@ -464,21 +462,10 @@
.qb-content-section {
.qb-elements-container {
&::after {
background: repeating-linear-gradient(
to bottom,
var(--bg-vanilla-300),
var(--bg-vanilla-300) 4px,
transparent 4px,
transparent 8px
);
}
.code-mirror-where-clause,
.query-aggregation-container,
.query-add-ons,
.metrics-aggregation-section-content,
.metrics-container {
.metrics-aggregation-section-content {
&::before {
border-left: 6px dotted var(--bg-vanilla-300);
}
@@ -542,6 +529,18 @@
.qb-entity-options {
.options {
.query-name {
&::before {
background: repeating-linear-gradient(
to bottom,
var(--bg-vanilla-300),
var(--bg-vanilla-300) 4px,
transparent 4px,
transparent 8px
);
}
}
.formula-name {
&::before {
background: repeating-linear-gradient(

View File

@@ -207,7 +207,6 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel}
signalSource={currentQuery.builder.queryData[0].source as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
queriesCount={1}

View File

@@ -1,13 +1,14 @@
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { Select } from 'antd';
import {
initialQueriesMap,
initialQueryMeterWithType,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { MetricNameSelector } from 'container/QueryBuilder/filters';
import { AggregatorFilter } from 'container/QueryBuilder/filters';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
@@ -43,12 +44,21 @@ export const MetricsSelect = memo(function MetricsSelect({
signalSourceChangeEnabled: boolean;
savePreviousQuery: boolean;
}): JSX.Element {
const [attributeKeys, setAttributeKeys] = useState<BaseAutocompleteData[]>([]);
const { handleChangeAggregatorAttribute } = useQueryOperations({
index,
query,
entityVersion: version,
});
const handleAggregatorAttributeChange = useCallback(
(value: BaseAutocompleteData, isEditMode?: boolean) => {
handleChangeAggregatorAttribute(value, isEditMode, attributeKeys || []);
},
[handleChangeAggregatorAttribute, attributeKeys],
);
const {
updateAllQueriesOperators,
handleSetQueryData,
@@ -154,10 +164,12 @@ export const MetricsSelect = memo(function MetricsSelect({
/>
)}
<MetricNameSelector
onChange={handleChangeAggregatorAttribute}
<AggregatorFilter
onChange={handleAggregatorAttributeChange}
query={query}
index={index}
signalSource={signalSource || ''}
setAttributeKeys={setAttributeKeys}
/>
</div>
);

View File

@@ -202,8 +202,8 @@ function QueryAddOns({
} else {
filteredAddOns = Object.values(ADD_ONS);
// Filter out group_by for metrics data source
if (query.dataSource === DataSource.METRICS) {
// Filter out group_by for metrics data source (handled in MetricsAggregateSection)
filteredAddOns = filteredAddOns.filter(
(addOn) => addOn.key !== ADD_ONS_KEYS.GROUP_BY,
);

View File

@@ -43,7 +43,6 @@ jest.mock(
);
jest.mock('container/QueryBuilder/filters', () => ({
AggregatorFilter: (): JSX.Element => <div />,
MetricNameSelector: (): JSX.Element => <div />,
}));
// Mock hooks
jest.mock('hooks/queryBuilder/useQueryBuilder');

View File

@@ -1,5 +1,4 @@
// ** Helpers
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
@@ -178,7 +177,7 @@ export const initialQueryBuilderFormValues: IBuilderQuery = {
{
metricName: '',
temporality: '',
timeAggregation: MetricAggregateOperator.AVG,
timeAggregation: MetricAggregateOperator.COUNT,
spaceAggregation: MetricAggregateOperator.SUM,
reduceTo: ReduceOperators.AVG,
},
@@ -226,7 +225,7 @@ export const initialQueryBuilderFormMeterValues: IBuilderQuery = {
{
metricName: '',
temporality: '',
timeAggregation: MeterAggregateOperator.AVG,
timeAggregation: MeterAggregateOperator.COUNT,
spaceAggregation: MeterAggregateOperator.SUM,
reduceTo: ReduceOperators.AVG,
},
@@ -372,31 +371,6 @@ export enum ATTRIBUTE_TYPES {
EXPONENTIAL_HISTOGRAM = 'ExponentialHistogram',
}
const METRIC_TYPE_TO_ATTRIBUTE_TYPE: Record<
MetrictypesTypeDTO,
ATTRIBUTE_TYPES
> = {
[MetrictypesTypeDTO.sum]: ATTRIBUTE_TYPES.SUM,
[MetrictypesTypeDTO.gauge]: ATTRIBUTE_TYPES.GAUGE,
[MetrictypesTypeDTO.histogram]: ATTRIBUTE_TYPES.HISTOGRAM,
[MetrictypesTypeDTO.summary]: ATTRIBUTE_TYPES.GAUGE,
[MetrictypesTypeDTO.exponentialhistogram]:
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
};
export function toAttributeType(
metricType: MetrictypesTypeDTO | undefined,
isMonotonic?: boolean,
): ATTRIBUTE_TYPES | '' {
if (!metricType) {
return '';
}
if (metricType === MetrictypesTypeDTO.sum && isMonotonic === false) {
return ATTRIBUTE_TYPES.GAUGE;
}
return METRIC_TYPE_TO_ATTRIBUTE_TYPE[metricType] || '';
}
export type IQueryBuilderState = 'search';
export const QUERY_BUILDER_SEARCH_VALUES = {

View File

@@ -56,6 +56,7 @@ const ROUTES = {
TRACE_EXPLORER: '/trace-explorer',
BILLING: '/settings/billing',
ROLES_SETTINGS: '/settings/roles',
MEMBERS_SETTINGS: '/settings/members',
SUPPORT: '/support',
LOGS_SAVE_VIEWS: '/logs/saved-views',
TRACES_SAVE_VIEWS: '/traces/saved-views',

View File

@@ -179,19 +179,6 @@
&__input.description {
color: var(--text-ink-300);
}
.ant-btn {
color: var(--text-ink-400);
background: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
&:hover,
&:focus {
color: var(--text-ink-500);
background: var(--bg-vanilla-200);
border-color: var(--bg-vanilla-400);
}
}
}
.edit-alert-header {

View File

@@ -441,7 +441,7 @@ describe('Footer utils', () => {
reduceTo: undefined,
spaceAggregation: 'sum',
temporality: undefined,
timeAggregation: 'avg',
timeAggregation: 'count',
},
],
disabled: false,

View File

@@ -1,4 +1,3 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
@@ -9,7 +8,7 @@ interface BaseChartProps {
height: number;
showTooltip?: boolean;
showLegend?: boolean;
timezone?: Timezone;
timezone: string;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;

View File

@@ -129,12 +129,12 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
onDestroy={onPlotDestroy}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone.value}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
isStackedBarChart={widget.stackedBarChart ?? false}
timezone={timezone}
>
<ContextMenu
coordinates={coordinates}

View File

@@ -5,7 +5,12 @@ import { getInitialStackedBands } from 'container/DashboardContainer/visualizati
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import {
DrawStyle,
LineInterpolation,
LineStyle,
VisibilityMode,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { get } from 'lodash-es';
import { Widgets } from 'types/api/dashboard/getAll';
@@ -58,12 +63,7 @@ export function prepareBarPanelConfig({
const minStepInterval = Math.min(...Object.values(stepIntervals));
const builder = buildBaseConfig({
id: widget.id,
thresholds: widget.thresholds,
yAxisUnit: widget.yAxisUnit,
softMin: widget.softMin ?? undefined,
softMax: widget.softMax ?? undefined,
isLogScale: widget.isLogScale,
widget,
isDarkMode,
onClick,
onDragSelect,
@@ -98,8 +98,14 @@ export function prepareBarPanelConfig({
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
panelType: PANEL_TYPES.BAR,
label: label,
colorMapping: widget.customLegendColors ?? {},
spanGaps: false,
lineStyle: LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline,
showPoints: VisibilityMode.Never,
pointSize: 5,
isDarkMode,
stepInterval: currentStepInterval,
});

View File

@@ -100,7 +100,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
syncMode={DashboardCursorSync.Crosshair}
timezone={timezone}
timezone={timezone.value}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}

View File

@@ -154,12 +154,7 @@ export function prepareHistogramPanelConfig({
isDarkMode: boolean;
}): UPlotConfigBuilder {
const builder = buildBaseConfig({
id: widget.id,
thresholds: widget.thresholds,
yAxisUnit: widget.yAxisUnit,
softMin: widget.softMin ?? undefined,
softMax: widget.softMax ?? undefined,
isLogScale: widget.isLogScale,
widget,
isDarkMode,
apiResponse,
panelMode,
@@ -196,8 +191,10 @@ export function prepareHistogramPanelConfig({
builder.addSeries({
label: '',
scaleKey: 'y',
drawStyle: DrawStyle.Histogram,
drawStyle: DrawStyle.Bar,
panelType: PANEL_TYPES.HISTOGRAM,
colorMapping: widget.customLegendColors ?? {},
spanGaps: false,
barWidthFactor: 1,
pointSize: 5,
lineColor: '#3f5ecc',
@@ -219,8 +216,10 @@ export function prepareHistogramPanelConfig({
builder.addSeries({
label: label,
scaleKey: 'y',
drawStyle: DrawStyle.Histogram,
drawStyle: DrawStyle.Bar,
panelType: PANEL_TYPES.HISTOGRAM,
colorMapping: widget.customLegendColors ?? {},
spanGaps: false,
barWidthFactor: 1,
pointSize: 5,
isDarkMode,

View File

@@ -118,7 +118,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
}}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
timezone={timezone.value}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}

View File

@@ -82,12 +82,7 @@ export const prepareUPlotConfig = ({
const minStepInterval = Math.min(...Object.values(stepIntervals));
const builder = buildBaseConfig({
id: widget.id,
thresholds: widget.thresholds,
yAxisUnit: widget.yAxisUnit,
softMin: widget.softMin ?? undefined,
softMax: widget.softMax ?? undefined,
isLogScale: widget.isLogScale,
widget,
isDarkMode,
onClick,
onDragSelect,
@@ -125,6 +120,7 @@ export const prepareUPlotConfig = ({
: VisibilityMode.Never,
pointSize: 5,
isDarkMode,
panelType: PANEL_TYPES.TIME_SERIES,
});
});

View File

@@ -1,11 +1,11 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { STEP_INTERVAL_MULTIPLIER } from 'lib/uPlotV2/constants';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { PanelMode } from '../../types';
import { BaseConfigBuilderProps, buildBaseConfig } from '../baseConfigBuilder';
import { buildBaseConfig } from '../baseConfigBuilder';
jest.mock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
@@ -27,25 +27,16 @@ jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
default: jest.fn().mockReturnValue({ name: 'onClickPlugin' }),
}));
const createBaseConfigBuilderProps = (
overrides: Partial<
Pick<
BaseConfigBuilderProps,
'id' | 'yAxisUnit' | 'isLogScale' | 'softMin' | 'softMax' | 'thresholds'
>
> = {},
): Pick<
BaseConfigBuilderProps,
'id' | 'yAxisUnit' | 'isLogScale' | 'softMin' | 'softMax' | 'thresholds'
> => ({
id: 'widget-1',
yAxisUnit: 'ms',
isLogScale: false,
softMin: undefined,
softMax: undefined,
thresholds: [],
...overrides,
});
const createWidget = (overrides: Partial<Widgets> = {}): Widgets =>
({
id: 'widget-1',
yAxisUnit: 'ms',
isLogScale: false,
softMin: undefined,
softMax: undefined,
thresholds: [],
...overrides,
} as Widgets);
const createApiResponse = (
overrides: Partial<MetricRangePayloadProps> = {},
@@ -56,7 +47,7 @@ const createApiResponse = (
} as MetricRangePayloadProps);
const baseProps = {
...createBaseConfigBuilderProps(),
widget: createWidget(),
apiResponse: createApiResponse(),
isDarkMode: true,
panelMode: PanelMode.DASHBOARD_VIEW,
@@ -72,14 +63,14 @@ describe('buildBaseConfig', () => {
expect(typeof builder.getLegendItems).toBe('function');
});
it('configures builder with id and DASHBOARD_VIEW preferences', () => {
it('configures builder with widgetId and DASHBOARD_VIEW preferences', () => {
const builder = buildBaseConfig({
...baseProps,
panelMode: PanelMode.DASHBOARD_VIEW,
...createBaseConfigBuilderProps({ id: 'my-widget' }),
widget: createWidget({ id: 'my-widget' }),
});
expect(builder.getId()).toBe('my-widget');
expect(builder.getWidgetId()).toBe('my-widget');
expect(builder.getShouldSaveSelectionPreference()).toBe(true);
});
@@ -136,7 +127,7 @@ describe('buildBaseConfig', () => {
it('configures log scale on y axis when widget.isLogScale is true', () => {
const builder = buildBaseConfig({
...baseProps,
...createBaseConfigBuilderProps({ isLogScale: true }),
widget: createWidget({ isLogScale: true }),
});
const config = builder.getConfig();
@@ -180,7 +171,7 @@ describe('buildBaseConfig', () => {
it('adds thresholds from widget', () => {
const builder = buildBaseConfig({
...baseProps,
...createBaseConfigBuilderProps({
widget: createWidget({
thresholds: [
{
thresholdValue: 80,
@@ -188,7 +179,7 @@ describe('buildBaseConfig', () => {
thresholdUnit: 'ms',
thresholdLabel: 'High',
},
] as ThresholdProps[],
] as Widgets['thresholds'],
}),
});

View File

@@ -1,6 +1,5 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import onClickPlugin, {
OnClickPluginOpts,
} from 'lib/uPlotLib/plugins/onClickPlugin';
@@ -10,32 +9,28 @@ import {
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { PanelMode } from '../types';
export interface BaseConfigBuilderProps {
id: string;
thresholds?: ThresholdProps[];
widget: Widgets;
apiResponse: MetricRangePayloadProps;
isDarkMode: boolean;
onClick?: OnClickPluginOpts['onClick'];
onDragSelect?: (startTime: number, endTime: number) => void;
timezone?: Timezone;
panelMode?: PanelMode;
panelMode: PanelMode;
panelType: PANEL_TYPES;
minTimeScale?: number;
maxTimeScale?: number;
stepInterval?: number;
isLogScale?: boolean;
yAxisUnit?: string;
softMin?: number;
softMax?: number;
}
export function buildBaseConfig({
id,
widget,
isDarkMode,
onClick,
onDragSelect,
@@ -43,14 +38,9 @@ export function buildBaseConfig({
timezone,
panelMode,
panelType,
thresholds,
minTimeScale,
maxTimeScale,
stepInterval,
isLogScale,
yAxisUnit,
softMin,
softMax,
}: BaseConfigBuilderProps): UPlotConfigBuilder {
const tzDate = timezone
? (timestamp: number): Date =>
@@ -58,27 +48,28 @@ export function buildBaseConfig({
: undefined;
const builder = new UPlotConfigBuilder({
id,
onDragSelect,
widgetId: widget.id,
tzDate,
shouldSaveSelectionPreference: panelMode === PanelMode.DASHBOARD_VIEW,
selectionPreferencesSource: panelMode
? [PanelMode.DASHBOARD_VIEW, PanelMode.STANDALONE_VIEW].includes(panelMode)
? SelectionPreferencesSource.LOCAL_STORAGE
: SelectionPreferencesSource.IN_MEMORY
selectionPreferencesSource: [
PanelMode.DASHBOARD_VIEW,
PanelMode.STANDALONE_VIEW,
].includes(panelMode)
? SelectionPreferencesSource.LOCAL_STORAGE
: SelectionPreferencesSource.IN_MEMORY,
stepInterval,
});
const thresholdOptions: ThresholdsDrawHookOptions = {
scaleKey: 'y',
thresholds: (thresholds || []).map((threshold) => ({
thresholds: (widget.thresholds || []).map((threshold) => ({
thresholdValue: threshold.thresholdValue ?? 0,
thresholdColor: threshold.thresholdColor,
thresholdUnit: threshold.thresholdUnit,
thresholdLabel: threshold.thresholdLabel,
})),
yAxisUnit: yAxisUnit,
yAxisUnit: widget.yAxisUnit,
};
builder.addThresholds(thresholdOptions);
@@ -88,8 +79,8 @@ export function buildBaseConfig({
time: true,
min: minTimeScale,
max: maxTimeScale,
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
logBase: widget.isLogScale ? 10 : undefined,
distribution: widget.isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
@@ -100,11 +91,11 @@ export function buildBaseConfig({
time: false,
min: undefined,
max: undefined,
softMin: softMin,
softMax: softMax,
softMin: widget.softMin ?? undefined,
softMax: widget.softMax ?? undefined,
thresholds: thresholdOptions,
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
logBase: widget.isLogScale ? 10 : undefined,
distribution: widget.isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
@@ -123,7 +114,7 @@ export function buildBaseConfig({
show: true,
side: 2,
isDarkMode,
isLogScale,
isLogScale: widget.isLogScale,
panelType,
});
@@ -132,8 +123,8 @@ export function buildBaseConfig({
show: true,
side: 3,
isDarkMode,
isLogScale,
yAxisUnit,
isLogScale: widget.isLogScale,
yAxisUnit: widget.yAxisUnit,
panelType,
});

View File

@@ -0,0 +1,105 @@
.members-settings {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 8px 24px 16px;
height: 100%;
}
.members-settings__header {
display: flex;
flex-direction: column;
gap: 4px;
.ant-typography {
margin-bottom: 0 !important;
}
}
.members-settings__title {
font-size: 18px !important;
font-weight: 500 !important;
color: var(--vanilla-100) !important;
letter-spacing: -0.09px;
line-height: 28px !important;
margin: 0 !important;
}
.members-settings__subtitle {
font-size: 14px;
color: var(--vanilla-400);
letter-spacing: -0.07px;
line-height: 20px;
}
.members-settings__controls {
display: flex;
align-items: center;
gap: 8px;
}
.members-filter-trigger {
display: flex;
align-items: center;
gap: 4px;
border: 1px solid var(--border);
border-radius: 2px;
background-color: var(--l2-background);
&__chevron {
flex-shrink: 0;
color: var(--card-foreground);
}
}
.members-filter-dropdown {
.ant-dropdown-menu {
background: rgba(18, 19, 23, 0.9);
border: 1px solid var(--slate-400);
border-radius: 4px;
backdrop-filter: blur(20px);
padding: 11px 13px;
box-shadow: 4px 10px 16px 0 rgba(0, 0, 0, 0.2);
}
.ant-dropdown-menu-item {
background: transparent;
padding: 4px 0;
&:hover {
background: transparent;
}
}
}
.members-filter-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
font-size: 14px;
color: var(--vanilla-100);
letter-spacing: 0.14px;
min-width: 170px;
&__check {
color: var(--vanilla-100);
font-size: 14px;
}
}
.members-settings__search {
flex: 1;
min-width: 0;
}
.members-search-input {
height: 32px;
color: var(--l1-foreground);
background-color: var(--l2-background);
border-color: var(--border);
&::placeholder {
color: var(--l2-foreground);
}
}

View File

@@ -0,0 +1,256 @@
import { useCallback, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/button';
import { ChevronDown, Plus } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import type { MenuProps } from 'antd';
import { Dropdown, Typography } from 'antd';
import getPendingInvites from 'api/v1/invite/get';
import getAll from 'api/v1/user/get';
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
import useUrlQuery from 'hooks/useUrlQuery';
import { useAppContext } from 'providers/App/App';
import './MembersSettings.styles.scss';
const PAGE_SIZE = 20;
type FilterMode = 'all' | 'invited';
function MembersSettings(): JSX.Element {
const { org } = useAppContext();
const history = useHistory();
const urlQuery = useUrlQuery();
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
const currentPage = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<FilterMode>('all');
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [selectedMember, setSelectedMember] = useState<MemberRow | null>(null);
const {
data: usersData,
isLoading: isUsersLoading,
refetch: refetchUsers,
} = useQuery({
queryFn: getAll,
queryKey: ['getOrgUser', org?.[0]?.id],
});
const {
data: invitesData,
isLoading: isInvitesLoading,
refetch: refetchInvites,
} = useQuery({
queryFn: getPendingInvites,
queryKey: ['getPendingInvites'],
});
const isLoading = isUsersLoading || isInvitesLoading;
const allMembers = useMemo((): MemberRow[] => {
const activeMembers: MemberRow[] = (usersData?.data ?? []).map((user) => ({
id: user.id,
name: user.displayName,
email: user.email,
role: user.role,
status: 'Active' as const,
joinedOn: user.createdAt ? String(user.createdAt) : null,
updatedAt: (user as { updatedAt?: number }).updatedAt
? String((user as { updatedAt?: number }).updatedAt)
: null,
}));
const pendingInvites: MemberRow[] = (invitesData?.data ?? []).map(
(invite) => ({
id: `invite-${invite.id}`,
name: invite.name ?? '',
email: invite.email,
role: invite.role,
status: 'Invited' as const,
joinedOn: invite.createdAt ? String(invite.createdAt) : null,
token: invite.token ?? null,
}),
);
return [...activeMembers, ...pendingInvites];
}, [usersData, invitesData]);
const filteredMembers = useMemo((): MemberRow[] => {
let result = allMembers;
if (filterMode === 'invited') {
result = result.filter((m) => m.status === 'Invited');
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(m) =>
m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q),
);
}
return result;
}, [allMembers, filterMode, searchQuery]);
const paginatedMembers = useMemo((): MemberRow[] => {
const start = (currentPage - 1) * PAGE_SIZE;
return filteredMembers.slice(start, start + PAGE_SIZE);
}, [filteredMembers, currentPage]);
const setPage = useCallback(
(page: number): void => {
urlQuery.set('page', String(page));
history.replace({ search: urlQuery.toString() });
},
[history, urlQuery],
);
const pendingCount = invitesData?.data?.length ?? 0;
const totalCount = allMembers.length;
const filterMenuItems: MenuProps['items'] = [
{
key: 'all',
label: (
<div className="members-filter-option">
<span>All members {totalCount}</span>
{filterMode === 'all' && (
<span className="members-filter-option__check"></span>
)}
</div>
),
onClick: (): void => {
setFilterMode('all');
setPage(1);
},
},
{
key: 'invited',
label: (
<div className="members-filter-option">
<span>Pending invites {pendingCount}</span>
{filterMode === 'invited' && (
<span className="members-filter-option__check"></span>
)}
</div>
),
onClick: (): void => {
setFilterMode('invited');
setPage(1);
},
},
];
const filterLabel =
filterMode === 'all'
? `All members ⎯ ${totalCount}`
: `Pending invites ⎯ ${pendingCount}`;
const handleInviteSuccess = useCallback((): void => {
refetchUsers();
refetchInvites();
}, [refetchUsers, refetchInvites]);
const handleRowClick = useCallback((member: MemberRow): void => {
setSelectedMember(member);
}, []);
const handleDrawerClose = useCallback((): void => {
setSelectedMember(null);
}, []);
const handleMemberEditSuccess = useCallback((): void => {
refetchUsers();
refetchInvites();
setSelectedMember(null);
}, [refetchUsers, refetchInvites]);
return (
<>
<div className="members-settings">
<div className="members-settings__header">
<Typography.Title level={5} className="members-settings__title">
Members
</Typography.Title>
<Typography.Text className="members-settings__subtitle">
Overview of people added to this workspace.
</Typography.Text>
</div>
{/* Controls row */}
<div className="members-settings__controls">
<Dropdown
menu={{ items: filterMenuItems }}
trigger={['click']}
overlayClassName="members-filter-dropdown"
>
<Button
variant="solid"
size="sm"
color="secondary"
className="members-filter-trigger"
>
<span>{filterLabel}</span>
<ChevronDown size={12} className="members-filter-trigger__chevron" />
</Button>
</Dropdown>
<div className="members-settings__search">
<Input
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e): void => {
setSearchQuery(e.target.value);
setPage(1);
}}
className="members-search-input"
color="secondary"
/>
</div>
<Button
variant="solid"
size="sm"
color="primary"
onClick={(): void => setIsInviteModalOpen(true)}
>
<Plus size={12} />
Invite member
</Button>
</div>
</div>
<MembersTable
data={paginatedMembers}
loading={isLoading}
total={filteredMembers.length}
currentPage={currentPage}
pageSize={PAGE_SIZE}
searchQuery={searchQuery}
onPageChange={setPage}
onRowClick={handleRowClick}
/>
<InviteMembersModal
open={isInviteModalOpen}
onClose={(): void => setIsInviteModalOpen(false)}
onSuccess={handleInviteSuccess}
/>
<EditMemberDrawer
member={selectedMember}
open={selectedMember !== null}
onClose={handleDrawerClose}
onSuccess={handleMemberEditSuccess}
/>
</>
);
}
export default MembersSettings;

View File

@@ -6,7 +6,6 @@ import { isAxiosError } from 'axios';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
@@ -116,34 +115,27 @@ function TimeSeries(): JSX.Element {
setYAxisUnit(value);
};
const hasMetricSelected = useMemo(
() => currentQuery.builder.queryData.some((q) => q.aggregateAttribute?.key),
[currentQuery],
);
return (
<div className="meter-time-series-container">
<BuilderUnitsFilter onChange={onUnitChangeHandler} yAxisUnit={yAxisUnit} />
<div className="time-series-container">
{!hasMetricSelected && <EmptyMetricsSearch />}
{hasMetricSelected &&
responseData.map((datapoint, index) => (
<div
className="time-series-view-panel"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading}
data={datapoint}
dataSource={DataSource.METRICS}
yAxisUnit={yAxisUnit}
panelType={PANEL_TYPES.BAR}
/>
</div>
))}
{responseData.map((datapoint, index) => (
<div
className="time-series-view-panel"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading}
data={datapoint}
dataSource={DataSource.METRICS}
yAxisUnit={yAxisUnit}
panelType={PANEL_TYPES.BAR}
/>
</div>
))}
</div>
</div>
);

View File

@@ -1,21 +1,13 @@
import { Typography } from 'antd';
import { Empty } from 'antd/lib';
interface EmptyMetricsSearchProps {
hasQueryResult?: boolean;
}
export default function EmptyMetricsSearch({
hasQueryResult,
}: EmptyMetricsSearchProps): JSX.Element {
export default function EmptyMetricsSearch(): JSX.Element {
return (
<div className="empty-metrics-search">
<Empty
description={
<Typography.Title level={5}>
{hasQueryResult
? 'No data'
: 'Select a metric and run a query to see the results'}
Please build and run a valid query to see the result
</Typography.Title>
}
/>

View File

@@ -69,7 +69,7 @@ function Explorer(): JSX.Element {
!isMetricUnitsLoading &&
!isMetricUnitsError &&
units.length > 0 &&
units.every((unit) => unit === units[0]),
units.every((unit) => unit && unit === units[0]),
[units, isMetricUnitsLoading, isMetricUnitsError],
);

View File

@@ -28,7 +28,6 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import EmptyMetricsSearch from './EmptyMetricsSearch';
import { TimeSeriesProps } from './types';
import {
buildUpdateMetricYAxisUnitPayload,
@@ -210,7 +209,7 @@ function TimeSeries({
{showSaveUnitButton && (
<div className="save-unit-container">
<Typography.Text>
Set the selected unit as the metric unit?
Save the selected unit for this metric?
</Typography.Text>
<Button
type="primary"
@@ -230,71 +229,64 @@ function TimeSeries({
'time-series-container': changeLayoutForOneChartPerQuery,
})}
>
{metricNames.length === 0 && <EmptyMetricsSearch />}
{metricNames.length > 0 &&
responseData.map((datapoint, index) => {
const isQueryDataItem = index < metricNames.length;
const metricName = isQueryDataItem ? metricNames[index] : undefined;
const metricUnit = isQueryDataItem ? metricUnits[index] : undefined;
{responseData.map((datapoint, index) => {
const isQueryDataItem = index < metricNames.length;
const metricName = isQueryDataItem ? metricNames[index] : undefined;
const metricUnit = isQueryDataItem ? metricUnits[index] : undefined;
// Show the no unit warning if -
// 1. The metric query is not loading
// 2. The metric units are not loading
// 3. There are more than one metric
// 4. The current metric unit is empty
// 5. Is a queryData item
const isMetricUnitEmpty =
isQueryDataItem &&
!queries[index].isLoading &&
!isMetricUnitsLoading &&
metricUnits.length > 1 &&
!metricUnit &&
metricName;
// Show the no unit warning if -
// 1. The metric query is not loading
// 2. The metric units are not loading
// 3. There are more than one metric
// 4. The current metric unit is empty
// 5. Is a queryData item
const isMetricUnitEmpty =
isQueryDataItem &&
!queries[index].isLoading &&
!isMetricUnitsLoading &&
metricUnits.length > 1 &&
!metricUnit &&
metricName;
const currentYAxisUnit = yAxisUnit || metricUnit;
const currentYAxisUnit = yAxisUnit || metricUnit;
return (
<div
className="time-series-view"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
{isMetricUnitEmpty && metricName && (
<Tooltip
className="no-unit-warning"
title={
<Typography.Text>
No unit is set for this metric. You can assign one from the{' '}
<Typography.Link
onClick={(): void => handleOpenMetricDetails(metricName)}
>
metric details
</Typography.Link>{' '}
page.
</Typography.Text>
}
>
<AlertTriangle
size={16}
color={Color.BG_AMBER_400}
role="img"
aria-label="no unit warning"
/>
</Tooltip>
)}
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading || isMetricUnitsLoading}
data={datapoint}
yAxisUnit={currentYAxisUnit}
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
/>
</div>
);
})}
return (
<div
className="time-series-view"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
{isMetricUnitEmpty && metricName && (
<Tooltip
className="no-unit-warning"
title={
<Typography.Text>
This metric does not have a unit. Please set one for it in the{' '}
<Typography.Link
onClick={(): void => handleOpenMetricDetails(metricName)}
>
metric details
</Typography.Link>{' '}
page.
</Typography.Text>
}
>
<AlertTriangle size={16} color={Color.BG_AMBER_400} />
</Tooltip>
)}
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading || isMetricUnitsLoading}
data={datapoint}
yAxisUnit={currentYAxisUnit}
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
/>
</div>
);
})}
</div>
</>
);

View File

@@ -1,19 +0,0 @@
import { render, screen } from '@testing-library/react';
import EmptyMetricsSearch from '../EmptyMetricsSearch';
describe('EmptyMetricsSearch', () => {
it('shows select metric message when no query has been run', () => {
render(<EmptyMetricsSearch />);
expect(
screen.getByText('Select a metric and run a query to see the results'),
).toBeInTheDocument();
});
it('shows no data message when a query returned empty results', () => {
render(<EmptyMetricsSearch hasQueryResult />);
expect(screen.getByText('No data')).toBeInTheDocument();
});
});

View File

@@ -8,7 +8,7 @@ import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap } from 'constants/queryBuilder';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import * as useOptionsMenuHooks from 'container/OptionsMenu';
import * as useUpdateDashboardHooks from 'hooks/dashboard/useUpdateDashboard';
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
@@ -157,6 +157,26 @@ describe('Explorer', () => {
jest.clearAllMocks();
});
it('should render Explorer query builder with metrics datasource selected', () => {
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
...mockUseQueryBuilderData,
stagedQuery: initialQueriesMap[DataSource.TRACES],
} as any);
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
mockSetSearchParams,
]);
renderExplorer();
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledWith(
initialQueriesMap[DataSource.METRICS],
PANEL_TYPES.TIME_SERIES,
DataSource.METRICS,
);
});
it('should enable one chart per query toggle when oneChartPerQuery=true in URL', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({ isOneChartPerQueryEnabled: 'true' }),
@@ -221,46 +241,20 @@ describe('Explorer', () => {
expect(yAxisUnitSelector).not.toBeInTheDocument();
});
it('one chart per query toggle should be forced on and disabled when multiple metrics have different units', () => {
const mockQueryData = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
.aggregateAttribute as BaseAutocompleteData),
key: 'metric1',
},
};
const mockStagedQueryWithMultipleQueries = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [mockQueryData, mockQueryData],
},
};
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: mockStagedQueryWithMultipleQueries,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
it('should hide y axis unit selector for multiple metrics with different units', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [
{ ...MOCK_METRIC_METADATA, unit: 'seconds' },
{ ...MOCK_METRIC_METADATA, unit: 'bytes' },
],
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
});
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
mockSetSearchParams,
]);
renderExplorer();
const yAxisUnitSelector = screen.queryByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
expect(yAxisUnitSelector).not.toBeInTheDocument();
// One chart per query toggle should be disabled
const oneChartPerQueryToggle = screen.getByRole('switch');
expect(oneChartPerQueryToggle).toBeChecked();
expect(oneChartPerQueryToggle).toBeDisabled();
});
@@ -333,53 +327,4 @@ describe('Explorer', () => {
const oneChartPerQueryToggle = screen.getByRole('switch');
expect(oneChartPerQueryToggle).toBeEnabled();
});
it('one chart per query toggle should be enabled when multiple metrics have no unit', () => {
const metricWithNoUnit = {
type: MetrictypesTypeDTO.sum,
description: 'metric without unit',
unit: '',
temporality: MetrictypesTemporalityDTO.cumulative,
isMonotonic: true,
};
const mockQueryData = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
.aggregateAttribute as BaseAutocompleteData),
key: 'metric1',
},
};
const mockStagedQueryWithMultipleQueries = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [mockQueryData, mockQueryData],
},
};
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: mockStagedQueryWithMultipleQueries,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [metricWithNoUnit, metricWithNoUnit],
});
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
mockSetSearchParams,
]);
renderExplorer();
const oneChartPerQueryToggle = screen.getByRole('switch');
// Toggle should be enabled (not forced/disabled) since both metrics
// have the same unit (no unit) and should be viewable on the same graph
expect(oneChartPerQueryToggle).toBeEnabled();
expect(oneChartPerQueryToggle).not.toBeChecked();
});
});

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
@@ -56,7 +56,7 @@ const mockSetYAxisUnit = jest.fn();
function renderTimeSeries(
overrides: Partial<TimeSeriesProps> = {},
): ReturnType<typeof render> {
): RenderResult {
return render(
<TimeSeries
showOneChartPerQuery={false}
@@ -84,57 +84,45 @@ describe('TimeSeries', () => {
} as Partial<UseUpdateMetricMetadataReturnType>) as UseUpdateMetricMetadataReturnType);
});
it('shows select metric message when no metric is selected', () => {
renderTimeSeries({ metricNames: [] });
expect(
screen.getByText('Select a metric and run a query to see the results'),
).toBeInTheDocument();
expect(screen.queryByText('TimeSeriesView')).not.toBeInTheDocument();
});
it('renders chart view when a metric is selected', () => {
renderTimeSeries({
metricNames: ['metric1'],
metricUnits: ['count'],
metrics: [MOCK_METRIC_METADATA],
});
expect(screen.getByText('TimeSeriesView')).toBeInTheDocument();
expect(
screen.queryByText('Select a metric and run a query to see the results'),
).not.toBeInTheDocument();
});
it('should render a warning icon when a metric has no unit among multiple metrics', () => {
renderTimeSeries({
const user = userEvent.setup();
const { container } = renderTimeSeries({
metricUnits: ['', 'count'],
metricNames: ['metric1', 'metric2'],
metrics: [undefined, undefined],
});
expect(
screen.getByRole('img', { name: 'no unit warning' }),
).toBeInTheDocument();
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
user.hover(alertIcon);
waitFor(() =>
expect(
screen.findByText('This metric does not have a unit'),
).toBeInTheDocument(),
);
});
it('warning tooltip shows metric details link', async () => {
it('clicking on warning icon tooltip should open metric details modal', async () => {
const user = userEvent.setup();
renderTimeSeries({
const { container } = renderTimeSeries({
metricUnits: ['', 'count'],
metricNames: ['metric1', 'metric2'],
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
yAxisUnit: 'seconds',
});
const alertIcon = screen.getByRole('img', { name: 'no unit warning' });
await user.hover(alertIcon);
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
user.hover(alertIcon);
expect(await screen.findByText('metric details')).toBeInTheDocument();
const metricDetailsLink = await screen.findByText('metric details');
user.click(metricDetailsLink);
waitFor(() =>
expect(mockSetIsMetricDetailsOpen).toHaveBeenCalledWith('metric1'),
);
});
it('shows save unit prompt with enabled button when metric has no unit and a unit is selected', async () => {
renderTimeSeries({
it('shows Save unit button when metric had no unit but one is selected', async () => {
const { findByText, getByRole } = renderTimeSeries({
metricUnits: [undefined],
metricNames: ['metric1'],
metrics: [MOCK_METRIC_METADATA],
@@ -143,10 +131,38 @@ describe('TimeSeries', () => {
});
expect(
await screen.findByText('Set the selected unit as the metric unit?'),
await findByText('Save the selected unit for this metric?'),
).toBeInTheDocument();
const yesButton = screen.getByRole('button', { name: 'Yes' });
const yesButton = getByRole('button', { name: 'Yes' });
expect(yesButton).toBeInTheDocument();
expect(yesButton).toBeEnabled();
});
it('clicking on save unit button shoould upated metric metadata', async () => {
const user = userEvent.setup();
const { getByRole } = renderTimeSeries({
metricUnits: [''],
metricNames: ['metric1'],
metrics: [MOCK_METRIC_METADATA],
yAxisUnit: 'seconds',
showYAxisUnitSelector: true,
});
const yesButton = getByRole('button', { name: /Yes/i });
await user.click(yesButton);
expect(mockUpdateMetricMetadata).toHaveBeenCalledWith(
{
pathParams: {
metricName: 'metric1',
},
data: expect.objectContaining({ unit: 'seconds' }),
},
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
});
});

View File

@@ -139,14 +139,4 @@ describe('getMetricUnits', () => {
expect(result).toHaveLength(1);
expect(result[0]).toBe('s');
});
it('should return undefined for metrics with no unit', () => {
const result = getMetricUnits([
{ ...MOCK_METRIC_METADATA, unit: '' },
{ ...MOCK_METRIC_METADATA, unit: '' },
]);
expect(result).toHaveLength(2);
expect(result[0]).toBeUndefined();
expect(result[1]).toBeUndefined();
});
});

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Typography } from 'antd';
import { initialQueriesMap } from 'constants/queryBuilder';
import { MetricNameSelector } from 'container/QueryBuilder/filters';
import { AggregatorFilter } from 'container/QueryBuilder/filters';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
@@ -27,7 +27,7 @@ function MetricNameSearch({
className="inspect-metrics-input-group metric-name-search"
>
<Typography.Text>From</Typography.Text>
<MetricNameSelector
<AggregatorFilter
defaultValue={searchText ?? ''}
query={initialQueriesMap[DataSource.METRICS].builder.queryData[0]}
onSelect={handleSetMetricName}

View File

@@ -1,9 +1,8 @@
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as metricsService from 'api/generated/services/metrics';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import * as appContextHooks from 'providers/App/App';
import store from 'store';
@@ -24,31 +23,27 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('api/generated/services/metrics', () => ({
useListMetrics: jest.fn().mockReturnValue({
isFetching: false,
isError: false,
data: { data: { metrics: [] } },
}),
useUpdateMetricMetadata: jest.fn().mockReturnValue({
mutate: jest.fn(),
isLoading: false,
}),
jest.mock('container/QueryBuilder/filters', () => ({
AggregatorFilter: ({ onSelect, onChange, defaultValue }: any): JSX.Element => (
<div data-testid="mock-aggregator-filter">
<input
data-testid="metric-name-input"
defaultValue={defaultValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>): void =>
onChange({ key: e.target.value })
}
/>
<button
type="button"
data-testid="select-metric-button"
onClick={(): void => onSelect({ key: 'test_metric_2' })}
>
Select Metric
</button>
</div>
),
}));
jest.mock('hooks/useDebounce', () => ({
__esModule: true,
default: <T,>(value: T): T => value,
}));
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer',
() => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
}),
);
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
user: {
role: 'admin',
@@ -128,24 +123,6 @@ describe('QueryBuilder', () => {
it('should call setCurrentMetricName when metric name is selected', async () => {
const user = userEvent.setup();
(metricsService.useListMetrics as jest.Mock).mockReturnValue({
isFetching: false,
isError: false,
data: {
data: {
metrics: [
{
metricName: 'test_metric_2',
type: 'Sum',
isMonotonic: true,
description: '',
temporality: 'cumulative',
unit: '',
},
],
},
},
});
render(
<QueryClientProvider client={queryClient}>
@@ -160,12 +137,8 @@ describe('QueryBuilder', () => {
expect(screen.getByText('From')).toBeInTheDocument();
const input = within(metricNameSearch).getByRole('combobox');
fireEvent.change(input, { target: { value: 'test_metric_2' } });
const options = document.querySelectorAll('.ant-select-item');
expect(options.length).toBeGreaterThan(0);
await user.click(options[0] as HTMLElement);
const selectButton = screen.getByTestId('select-metric-button');
await user.click(selectButton);
expect(mockSetCurrentMetricName).toHaveBeenCalledWith('test_metric_2');
});

View File

@@ -33,7 +33,6 @@ const COPY_FEEDBACK_DURATION_MS = 1500;
function AllAttributes({
metricName,
metricType,
isMonotonic,
minTime,
maxTime,
}: AllAttributesProps): JSX.Element {
@@ -74,7 +73,6 @@ function AllAttributes({
undefined,
groupBy,
limit,
isMonotonic,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -84,7 +82,6 @@ function AllAttributes({
id: metricName,
},
ROUTES.METRICS_EXPLORER_EXPLORER,
true,
);
logEvent(MetricsExplorerEvents.OpenInExplorerClicked, {
[MetricsExplorerEventKeys.MetricName]: metricName,
@@ -93,19 +90,15 @@ function AllAttributes({
[MetricsExplorerEventKeys.AttributeKey]: groupBy,
});
},
[metricName, metricType, isMonotonic, handleExplorerTabChange],
[metricName, metricType, handleExplorerTabChange],
);
const goToMetricsExploreWithAppliedAttribute = useCallback(
(key: string, value: string) => {
const compositeQuery = getMetricDetailsQuery(
metricName,
metricType,
{ key, value },
undefined,
undefined,
isMonotonic,
);
const compositeQuery = getMetricDetailsQuery(metricName, metricType, {
key,
value,
});
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
{
@@ -114,7 +107,6 @@ function AllAttributes({
id: metricName,
},
ROUTES.METRICS_EXPLORER_EXPLORER,
true,
);
logEvent(MetricsExplorerEvents.OpenInExplorerClicked, {
[MetricsExplorerEventKeys.MetricName]: metricName,
@@ -124,7 +116,7 @@ function AllAttributes({
[MetricsExplorerEventKeys.AttributeValue]: value,
});
},
[metricName, metricType, isMonotonic, handleExplorerTabChange],
[metricName, metricType, handleExplorerTabChange],
);
const handleKeyMenuItemClick = useCallback(

View File

@@ -1,6 +1,7 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Button, Input, Menu, Popover, Tooltip, Typography } from 'antd';
import { useNotifications } from 'hooks/useNotifications';
import { Check, Copy, Search, SquareArrowOutUpRight } from 'lucide-react';
import MetricDetailsErrorState from './MetricDetailsErrorState';
@@ -38,6 +39,7 @@ export function AllAttributesValue({
const [allValuesSearch, setAllValuesSearch] = useState('');
const [copiedValue, setCopiedValue] = useState<string | null>(null);
const [, copyToClipboard] = useCopyToClipboard();
const { notifications } = useNotifications();
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
const handleCopyWithFeedback = useCallback(
@@ -60,13 +62,21 @@ export function AllAttributesValue({
break;
case 'copy-value':
handleCopyWithFeedback(attribute);
notifications.success({
message: 'Value copied!',
});
break;
default:
break;
}
setAttributePopoverKey(null);
},
[goToMetricsExploreWithAppliedAttribute, filterKey, handleCopyWithFeedback],
[
goToMetricsExploreWithAppliedAttribute,
filterKey,
handleCopyWithFeedback,
notifications,
],
);
const attributePopoverContent = useCallback(
@@ -159,35 +169,26 @@ export function AllAttributesValue({
return (
<div className="all-attributes-value">
{filterValue.slice(0, INITIAL_VISIBLE_COUNT).map((attribute) => {
const isCopied = copiedValue === attribute;
return (
<div key={attribute} className="all-attributes-value-item">
<Popover
content={attributePopoverContent(attribute)}
trigger="click"
overlayClassName="metric-details-popover attribute-value-popover-overlay"
open={attributePopoverKey === `${filterKey}-${attribute}`}
onOpenChange={(open): void => {
if (!open) {
setAttributePopoverKey(null);
} else {
setAttributePopoverKey(`${filterKey}-${attribute}`);
}
}}
>
<Button type="text">
<Typography.Text>{attribute}</Typography.Text>
</Button>
</Popover>
{isCopied && (
<span className="copy-feedback">
<Check size={12} />
</span>
)}
</div>
);
})}
{filterValue.slice(0, INITIAL_VISIBLE_COUNT).map((attribute) => (
<Popover
key={attribute}
content={attributePopoverContent(attribute)}
trigger="click"
overlayClassName="metric-details-popover attribute-value-popover-overlay"
open={attributePopoverKey === `${filterKey}-${attribute}`}
onOpenChange={(open): void => {
if (!open) {
setAttributePopoverKey(null);
} else {
setAttributePopoverKey(`${filterKey}-${attribute}`);
}
}}
>
<Button key={attribute} type="text">
<Typography.Text>{attribute}</Typography.Text>
</Button>
</Popover>
))}
{filterValue.length > INITIAL_VISIBLE_COUNT && (
<Popover
content={allValuesPopoverContent}

View File

@@ -9,7 +9,9 @@ import {
} from 'api/generated/services/metrics';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { Bell, Grid } from 'lucide-react';
import { pluralize } from 'utils/pluralize';
@@ -18,6 +20,7 @@ import { DashboardsAndAlertsPopoverProps } from './types';
function DashboardsAndAlertsPopover({
metricName,
}: DashboardsAndAlertsPopoverProps): JSX.Element | null {
const { safeNavigate } = useSafeNavigate();
const params = useUrlQuery();
const {
@@ -72,7 +75,7 @@ function DashboardsAndAlertsPopover({
key={alert.alertId}
onClick={(): void => {
params.set(QueryParams.ruleId, alert.alertId);
window.open(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`, '_blank');
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
}}
className="dashboards-popover-content-item"
>
@@ -92,11 +95,10 @@ function DashboardsAndAlertsPopover({
<Typography.Link
key={dashboard.dashboardId}
onClick={(): void => {
window.open(
safeNavigate(
generatePath(ROUTES.DASHBOARD, {
dashboardId: dashboard.dashboardId,
}),
'_blank',
);
}}
className="dashboards-popover-content-item"
@@ -107,7 +109,7 @@ function DashboardsAndAlertsPopover({
}));
}
return null;
}, [dashboards]);
}, [dashboards, safeNavigate]);
if (isLoadingAlerts || isLoadingDashboards) {
return (

View File

@@ -245,18 +245,6 @@
background: var(--bg-slate-300);
border-radius: 1px;
}
.all-attributes-value-item {
display: flex;
align-items: center;
gap: 4px;
.copy-feedback {
display: inline-flex;
align-items: center;
color: var(--bg-forest-500);
animation: fade-in-out 1.5s ease-in-out;
}
}
.ant-btn {
text-align: left;
width: fit-content;
@@ -410,8 +398,9 @@
}
.all-attributes-key {
.all-attributes-contribution {
color: var(--bg-slate-400);
.ant-typography:last-child {
color: var(--bg-vanilla-200);
background-color: var(--bg-robin-300);
}
}
}
@@ -593,14 +582,6 @@
background: var(--bg-vanilla-100) !important;
}
}
.all-values-popover {
.all-values-item {
&:hover {
background: rgba(0, 0, 0, 0.04);
}
}
}
}
@keyframes fade-in-out {

View File

@@ -80,14 +80,7 @@ function MetricDetails({
const goToMetricsExplorerwithSelectedMetric = useCallback(() => {
if (metricName) {
const compositeQuery = getMetricDetailsQuery(
metricName,
metadata?.type,
undefined,
undefined,
undefined,
metadata?.isMonotonic,
);
const compositeQuery = getMetricDetailsQuery(metricName, metadata?.type);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
{
@@ -96,7 +89,6 @@ function MetricDetails({
id: metricName,
},
ROUTES.METRICS_EXPLORER_EXPLORER,
true,
);
logEvent(MetricsExplorerEvents.OpenInExplorerClicked, {
[MetricsExplorerEventKeys.MetricName]: metricName,
@@ -104,12 +96,7 @@ function MetricDetails({
[MetricsExplorerEventKeys.Modal]: 'metric-details',
});
}
}, [
metricName,
handleExplorerTabChange,
metadata?.type,
metadata?.isMonotonic,
]);
}, [metricName, handleExplorerTabChange, metadata?.type]);
useEffect(() => {
logEvent(MetricsExplorerEvents.ModalOpened, {
@@ -195,7 +182,6 @@ function MetricDetails({
<AllAttributes
metricName={metricName}
metricType={metadata?.type}
isMonotonic={metadata?.isMonotonic}
minTime={minTime}
maxTime={maxTime}
/>

View File

@@ -14,8 +14,12 @@ import {
MOCK_METRIC_NAME,
} from './testUtlls';
const mockWindowOpen = jest.fn();
Object.defineProperty(window, 'open', { value: mockWindowOpen });
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
}));
const mockSetQuery = jest.fn();
const mockUrlQuery = {
set: mockSetQuery,
@@ -39,7 +43,6 @@ describe('DashboardsAndAlertsPopover', () => {
beforeEach(() => {
useGetMetricAlertsMock.mockReturnValue(getMockAlertsData());
useGetMetricDashboardsMock.mockReturnValue(getMockDashboardsData());
mockWindowOpen.mockClear();
});
it('renders the popover correctly with multiple dashboards and alerts', () => {
@@ -137,10 +140,9 @@ describe('DashboardsAndAlertsPopover', () => {
// Click on the first dashboard
await userEvent.click(screen.getByText(MOCK_DASHBOARD_1.dashboardName));
// Should open dashboard in new tab
expect(mockWindowOpen).toHaveBeenCalledWith(
// Should navigate to the dashboard
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/dashboard/${MOCK_DASHBOARD_1.dashboardId}`,
'_blank',
);
});
@@ -156,12 +158,11 @@ describe('DashboardsAndAlertsPopover', () => {
// Click on the first alert rule
await userEvent.click(screen.getByText(MOCK_ALERT_1.alertName));
// Should open alert in new tab
// Should navigate to the alert rule
expect(mockSetQuery).toHaveBeenCalledWith(
QueryParams.ruleId,
MOCK_ALERT_1.alertId,
);
expect(mockWindowOpen).toHaveBeenCalled();
});
it('renders unique dashboards even when there are duplicates', async () => {

View File

@@ -2,7 +2,6 @@ import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
import {
determineIsMonotonic,
@@ -140,7 +139,7 @@ describe('MetricDetails utils', () => {
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
ATTRIBUTE_TYPES.SUM,
MetrictypesTypeDTO.sum,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
@@ -157,7 +156,7 @@ describe('MetricDetails utils', () => {
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
ATTRIBUTE_TYPES.GAUGE,
MetrictypesTypeDTO.gauge,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
@@ -174,7 +173,7 @@ describe('MetricDetails utils', () => {
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
ATTRIBUTE_TYPES.GAUGE,
MetrictypesTypeDTO.summary,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
@@ -191,7 +190,7 @@ describe('MetricDetails utils', () => {
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
ATTRIBUTE_TYPES.HISTOGRAM,
MetrictypesTypeDTO.histogram,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
@@ -208,7 +207,7 @@ describe('MetricDetails utils', () => {
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
MetrictypesTypeDTO.exponentialhistogram,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');

View File

@@ -34,7 +34,6 @@ export interface MetadataProps {
export interface AllAttributesProps {
metricName: string;
metricType: MetrictypesTypeDTO | undefined;
isMonotonic?: boolean;
minTime?: number;
maxTime?: number;
}

View File

@@ -5,7 +5,7 @@ import {
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SpaceAggregation, TimeAggregation } from 'api/v5/v5';
import { initialQueriesMap, toAttributeType } from 'constants/queryBuilder';
import { initialQueriesMap } from 'constants/queryBuilder';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -88,25 +88,15 @@ export function getMetricDetailsQuery(
filter?: { key: string; value: string },
groupBy?: string,
limit?: number,
isMonotonic?: boolean,
): Query {
let timeAggregation;
let spaceAggregation;
let aggregateOperator;
const isNonMonotonicSum =
metricType === MetrictypesTypeDTO.sum && isMonotonic === false;
switch (metricType) {
case MetrictypesTypeDTO.sum:
if (isNonMonotonicSum) {
timeAggregation = 'avg';
spaceAggregation = 'avg';
aggregateOperator = 'avg';
} else {
timeAggregation = 'rate';
spaceAggregation = 'sum';
aggregateOperator = 'rate';
}
timeAggregation = 'rate';
spaceAggregation = 'sum';
aggregateOperator = 'rate';
break;
case MetrictypesTypeDTO.gauge:
timeAggregation = 'avg';
@@ -131,8 +121,6 @@ export function getMetricDetailsQuery(
break;
}
const attributeType = toAttributeType(metricType, isMonotonic);
return {
...initialQueriesMap[DataSource.METRICS],
builder: {
@@ -141,8 +129,8 @@ export function getMetricDetailsQuery(
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
key: metricName,
type: attributeType,
id: `${metricName}----${attributeType}---string--`,
type: metricType ?? '',
id: `${metricName}----${metricType}---string--`,
dataType: DataTypes.String,
},
aggregations: [

View File

@@ -312,9 +312,7 @@ function Summary(): JSX.Element {
/>
{showFullScreenLoading ? (
<MetricsLoading />
) : isMetricsListDataEmpty &&
isMetricsTreeMapDataEmpty &&
!appliedFilterExpression ? (
) : isMetricsListDataEmpty && isMetricsTreeMapDataEmpty ? (
<NoLogs dataSource={DataSource.METRICS} />
) : (
<>

View File

@@ -24,7 +24,6 @@ import {
AggregatorFilter,
GroupByFilter,
HavingFilter,
MetricNameSelector,
OperatorsSelect,
OrderByFilter,
ReduceToFilter,
@@ -404,7 +403,7 @@ export const Query = memo(function Query({
)}
<Col flex="auto">
<MetricNameSelector
<AggregatorFilter
onChange={handleChangeAggregatorAttribute}
query={query}
/>

View File

@@ -1,6 +0,0 @@
.metric-name-selector {
.ant-select-selection-placeholder {
color: var(--bg-slate-200);
font-style: italic;
}
}

View File

@@ -1,887 +0,0 @@
import { useEffect, useState } from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import {
MetricsexplorertypesListMetricDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { MetricNameSelector } from './MetricNameSelector';
const mockUseListMetrics = jest.fn();
jest.mock('api/generated/services/metrics', () => ({
useListMetrics: (...args: unknown[]): ReturnType<typeof mockUseListMetrics> =>
mockUseListMetrics(...args),
}));
jest.mock('hooks/useDebounce', () => ({
__esModule: true,
default: <T,>(value: T): T => value,
}));
jest.mock('../QueryBuilderSearch/OptionRenderer', () => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
}));
// Ref lets StatefulMetricQueryHarness wire handleSetQueryData to real state,
// while other tests keep the default no-op mock.
const handleSetQueryDataRef: {
current: (index: number, query: IBuilderQuery) => void;
} = {
current: jest.fn(),
};
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): Record<string, unknown> => ({
handleSetQueryData: (index: number, query: IBuilderQuery): void =>
handleSetQueryDataRef.current(index, query),
handleSetTraceOperatorData: jest.fn(),
handleSetFormulaData: jest.fn(),
removeQueryBuilderEntityByIndex: jest.fn(),
panelType: 'TIME_SERIES',
initialDataSource: DataSource.METRICS,
currentQuery: {
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
queryType: 'builder',
},
setLastUsedQuery: jest.fn(),
redirectWithQueryBuilderData: jest.fn(),
}),
}));
function makeMetric(
overrides: Partial<MetricsexplorertypesListMetricDTO> = {},
): MetricsexplorertypesListMetricDTO {
return {
metricName: 'http_requests_total',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
description: '',
temporality: 'cumulative' as never,
unit: '',
...overrides,
};
}
function makeQuery(overrides: Partial<IBuilderQuery> = {}): IBuilderQuery {
return {
dataSource: DataSource.METRICS,
queryName: 'A',
aggregateOperator: 'count',
aggregateAttribute: { key: '', type: '', dataType: DataTypes.Float64 },
timeAggregation: 'avg',
spaceAggregation: 'sum',
filter: { expression: '' },
aggregations: [],
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
limit: null,
orderBy: [],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
...overrides,
} as IBuilderQuery;
}
function returnMetrics(
metrics: MetricsexplorertypesListMetricDTO[],
overrides: Record<string, unknown> = {},
): void {
mockUseListMetrics.mockReturnValue({
isFetching: false,
isError: false,
data: { data: { metrics } },
queryKey: ['/api/v2/metrics'],
...overrides,
});
}
// snippet so tests can assert on them.
function MetricQueryHarness({ query }: { query: IBuilderQuery }): JSX.Element {
const {
handleChangeAggregatorAttribute,
operators,
spaceAggregationOptions,
} = useQueryOperations({
query,
index: 0,
entityVersion: ENTITY_VERSION_V5,
});
return (
<div>
<MetricNameSelector
query={query}
onChange={handleChangeAggregatorAttribute}
/>
<ul data-testid="time-agg-options">
{operators.map((op) => (
<li key={op.value}>{op.label}</li>
))}
</ul>
<ul data-testid="space-agg-options">
{spaceAggregationOptions.map((op) => (
<li key={op.value}>{op.label}</li>
))}
</ul>
</div>
);
}
function getOptionLabels(testId: string): string[] {
const list = screen.getByTestId(testId);
const items = within(list).queryAllByRole('listitem');
return items.map((el) => el.textContent || '');
}
describe('MetricNameSelector', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('shows metric names from API as dropdown options', () => {
returnMetrics([
makeMetric({ metricName: 'http_requests_total' }),
makeMetric({
metricName: 'cpu_usage_percent',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'h' } });
expect(
screen.getAllByText('http_requests_total').length,
).toBeGreaterThanOrEqual(1);
expect(
screen.getAllByText('cpu_usage_percent').length,
).toBeGreaterThanOrEqual(1);
});
it('retains typed metric name in input after blur', () => {
returnMetrics([makeMetric({ metricName: 'http_requests_total' })]);
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'http_requests_total' } });
fireEvent.blur(input);
expect(input).toHaveValue('http_requests_total');
});
it('shows error message when API request fails', () => {
mockUseListMetrics.mockReturnValue({
isFetching: false,
isError: true,
data: undefined,
queryKey: ['/api/v2/metrics'],
});
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
const input = screen.getByRole('combobox');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: 'test' } });
expect(screen.getByText('Failed to load metrics')).toBeInTheDocument();
});
it('shows loading spinner while fetching metrics', () => {
mockUseListMetrics.mockReturnValue({
isFetching: true,
isError: false,
data: undefined,
queryKey: ['/api/v2/metrics'],
});
const { container } = render(
<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'test' } });
expect(container.querySelector('.ant-spin-spinning')).toBeInTheDocument();
});
});
describe('selecting a metric type updates the aggregation options', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('Sum metric shows Rate/Increase time options and Sum/Avg/Min/Max space options', () => {
returnMetrics([
makeMetric({
metricName: 'http_requests_total',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'http_requests_total' } });
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual(['Rate', 'Increase']);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
it('Gauge metric shows Latest/Sum/Avg/Min/Max/Count/Count Distinct time options and Sum/Avg/Min/Max space options', () => {
returnMetrics([
makeMetric({
metricName: 'cpu_usage_percent',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'cpu_usage_percent' } });
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([
'Latest',
'Sum',
'Avg',
'Min',
'Max',
'Count',
'Count Distinct',
]);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
it('non-monotonic Sum metric is treated as Gauge', () => {
returnMetrics([
makeMetric({
metricName: 'active_connections',
type: MetrictypesTypeDTO.sum,
isMonotonic: false,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'active_connections' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([
'Latest',
'Sum',
'Avg',
'Min',
'Max',
'Count',
'Count Distinct',
]);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
it('Histogram metric shows no time options and P50P99 space options', () => {
returnMetrics([
makeMetric({
metricName: 'request_duration_seconds',
type: MetrictypesTypeDTO.histogram,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'request_duration_seconds' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([]);
expect(getOptionLabels('space-agg-options')).toEqual([
'P50',
'P75',
'P90',
'P95',
'P99',
]);
});
it('ExponentialHistogram metric shows no time options and P50P99 space options', () => {
returnMetrics([
makeMetric({
metricName: 'request_duration_exp',
type: MetrictypesTypeDTO.exponentialhistogram,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'request_duration_exp' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([]);
expect(getOptionLabels('space-agg-options')).toEqual([
'P50',
'P75',
'P90',
'P95',
'P99',
]);
});
it('unknown metric (typed name not in API results) shows all time and space options', () => {
returnMetrics([makeMetric({ metricName: 'known_metric' })]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'unknown_metric' } });
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([
'Max',
'Min',
'Sum',
'Avg',
'Count',
'Rate',
'Increase',
]);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
'P50',
'P75',
'P90',
'P95',
'P99',
]);
});
});
// these tests require the previous state, so we setup it to
// tracks previousMetricInfo across metric selections
function StatefulMetricQueryHarness({
initialQuery,
}: {
initialQuery: IBuilderQuery;
}): JSX.Element {
const [query, setQuery] = useState(initialQuery);
useEffect(() => {
handleSetQueryDataRef.current = (
_index: number,
newQuery: IBuilderQuery,
): void => {
setQuery(newQuery);
};
return (): void => {
handleSetQueryDataRef.current = jest.fn();
};
}, []);
const {
handleChangeAggregatorAttribute,
operators,
spaceAggregationOptions,
} = useQueryOperations({
query,
index: 0,
entityVersion: ENTITY_VERSION_V5,
});
const currentAggregation = query.aggregations?.[0] as MetricAggregation;
return (
<div>
<MetricNameSelector
query={query}
onChange={handleChangeAggregatorAttribute}
/>
<ul data-testid="time-agg-options">
{operators.map((op) => (
<li key={op.value}>{op.label}</li>
))}
</ul>
<ul data-testid="space-agg-options">
{spaceAggregationOptions.map((op) => (
<li key={op.value}>{op.label}</li>
))}
</ul>
<div data-testid="selected-time-agg">
{currentAggregation?.timeAggregation || ''}
</div>
<div data-testid="selected-space-agg">
{currentAggregation?.spaceAggregation || ''}
</div>
<div data-testid="selected-metric-name">
{currentAggregation?.metricName || ''}
</div>
</div>
);
}
describe('switching between metrics of the same type preserves aggregation settings', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('Sum: preserves non-default increase/avg when switching to another Sum metric', () => {
returnMetrics([
makeMetric({
metricName: 'metric_a',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
makeMetric({
metricName: 'metric_b',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'metric_a',
type: ATTRIBUTE_TYPES.SUM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'increase',
spaceAggregation: 'avg',
metricName: 'metric_a',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('increase');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'metric_b' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('increase');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'metric_b',
);
});
it('Gauge: preserves non-default min/max when switching to another Gauge metric', () => {
returnMetrics([
makeMetric({
metricName: 'cpu_usage',
type: MetrictypesTypeDTO.gauge,
}),
makeMetric({
metricName: 'mem_usage',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'cpu_usage',
type: ATTRIBUTE_TYPES.GAUGE,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'min',
spaceAggregation: 'max',
metricName: 'cpu_usage',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'mem_usage' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'mem_usage',
);
});
it('Histogram: preserves non-default p99 when switching to another Histogram metric', () => {
returnMetrics([
makeMetric({
metricName: 'req_duration',
type: MetrictypesTypeDTO.histogram,
}),
makeMetric({
metricName: 'db_latency',
type: MetrictypesTypeDTO.histogram,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'req_duration',
type: ATTRIBUTE_TYPES.HISTOGRAM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: '',
spaceAggregation: 'p99',
metricName: 'req_duration',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p99');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'db_latency' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p99');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'db_latency',
);
});
it('ExponentialHistogram: preserves non-default p75 when switching to another ExponentialHistogram metric', () => {
returnMetrics([
makeMetric({
metricName: 'exp_hist_a',
type: MetrictypesTypeDTO.exponentialhistogram,
}),
makeMetric({
metricName: 'exp_hist_b',
type: MetrictypesTypeDTO.exponentialhistogram,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'exp_hist_a',
type: ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: '',
spaceAggregation: 'p75',
metricName: 'exp_hist_a',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p75');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'exp_hist_b' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p75');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'exp_hist_b',
);
});
});
describe('switching to a different metric type resets aggregation to new defaults', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('Sum to Gauge: resets from increase/avg to the Gauge defaults avg/avg', () => {
returnMetrics([
makeMetric({
metricName: 'sum_metric',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
makeMetric({
metricName: 'gauge_metric',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'sum_metric',
type: ATTRIBUTE_TYPES.SUM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'increase',
spaceAggregation: 'avg',
metricName: 'sum_metric',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'gauge_metric' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'gauge_metric',
);
});
it('Gauge to Histogram: resets from min/max to the Histogram defaults (no time, p90 space)', () => {
returnMetrics([
makeMetric({
metricName: 'gauge_metric',
type: MetrictypesTypeDTO.gauge,
}),
makeMetric({
metricName: 'hist_metric',
type: MetrictypesTypeDTO.histogram,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'gauge_metric',
type: ATTRIBUTE_TYPES.GAUGE,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'min',
spaceAggregation: 'max',
metricName: 'gauge_metric',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'hist_metric' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p90');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'hist_metric',
);
});
it('Histogram to Sum: resets from p99 to the Sum defaults rate/sum', () => {
returnMetrics([
makeMetric({
metricName: 'hist_metric',
type: MetrictypesTypeDTO.histogram,
}),
makeMetric({
metricName: 'sum_metric',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'hist_metric',
type: ATTRIBUTE_TYPES.HISTOGRAM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: '',
spaceAggregation: 'p99',
metricName: 'hist_metric',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'sum_metric' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('rate');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('sum');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'sum_metric',
);
});
});
describe('typed metric not in search results is committed with unknown defaults', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('Gauge to unknown metric: resets from Gauge aggregations to unknown defaults (avg/avg)', () => {
returnMetrics([
makeMetric({
metricName: 'cpu_usage',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'cpu_usage',
type: ATTRIBUTE_TYPES.GAUGE,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'min',
spaceAggregation: 'max',
metricName: 'cpu_usage',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'unknown_metric' } });
fireEvent.blur(input);
// Metric not in search results is committed with empty type resets to unknown defaults
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'unknown_metric',
);
});
});
describe('Summary metric type is treated as Gauge', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('selecting a Summary metric shows Gauge aggregation options', () => {
returnMetrics([
makeMetric({
metricName: 'rpc_duration_summary',
type: MetrictypesTypeDTO.summary,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'rpc_duration_summary' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([
'Latest',
'Sum',
'Avg',
'Min',
'Max',
'Count',
'Count Distinct',
]);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
});

View File

@@ -1,266 +0,0 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { AutoComplete, Spin, Typography } from 'antd';
import { useListMetrics } from 'api/generated/services/metrics';
import { MetricsexplorertypesListMetricDTO } from 'api/generated/services/sigNoz.schemas';
import { ATTRIBUTE_TYPES, toAttributeType } from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import useDebounce from 'hooks/useDebounce';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { ExtendedSelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import './MetricNameSelector.styles.scss';
export type MetricNameSelectorProps = {
query: IBuilderQuery;
onChange: (value: BaseAutocompleteData, isEditMode?: boolean) => void;
disabled?: boolean;
defaultValue?: string;
onSelect?: (value: BaseAutocompleteData) => void;
signalSource?: 'meter' | '';
};
function getAttributeType(
metric: MetricsexplorertypesListMetricDTO,
): ATTRIBUTE_TYPES | '' {
return toAttributeType(metric.type, metric.isMonotonic);
}
function createAutocompleteData(
metricName: string,
type: string,
): BaseAutocompleteData {
return { key: metricName, type, dataType: DataTypes.Float64 };
}
// N.B on the metric name selector behaviour.
//
// Metric aggregation options resolution:
// The component maintains a ref (metricsRef) of the latest API results.
// When the user commits a metric name (via dropdown select, blur, or Cmd+Enter),
// resolveMetricFromText looks up the metric in metricsRef to determine its type
// (Sum, Gauge, Histogram, etc.). If the metric isn't found (e.g. the user typed
// a name before the debounced search returned), the type is empty and downstream
// treats it as unknown.
//
// Selection handling:
// - Dropdown select: user picks from the dropdown; type is always resolved
// since the option came from the current API results.
// - Blur: user typed a name and tabbed/clicked away without selecting from
// the dropdown. If the name differs from the current metric, it's resolved
// and committed. If the input is empty, it resets to the current metric name.
// - Cmd/Ctrl+Enter: resolves the typed name and commits it using flushSync
// so the state update is processed synchronously before QueryBuilderV2's
// onKeyDownCapture fires handleRunQuery. Uses document-level capture phase
// to run before React's root-level event dispatch. However, there is still one
// need to be handled here. TODO(srikanthccv): enter before n/w req completion
//
// Edit mode:
// When a saved query is loaded, the metric name may be set via aggregations
// but aggregateAttribute.type may be missing. Once the API returns metric data,
// the component calls onChange with isEditMode=true to backfill the type without
// resetting aggregation options.
//
// Signal source:
// When signalSource is 'meter', the API is filtered to meter metrics only.
// Changing signalSource clears the input and search text.
export const MetricNameSelector = memo(function MetricNameSelector({
query,
onChange,
disabled,
defaultValue,
onSelect,
signalSource,
}: MetricNameSelectorProps): JSX.Element {
const currentMetricName =
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
query.aggregateAttribute?.key ||
'';
const [inputValue, setInputValue] = useState<string>(
currentMetricName || defaultValue || '',
);
const [searchText, setSearchText] = useState<string>(currentMetricName);
const metricsRef = useRef<MetricsexplorertypesListMetricDTO[]>([]);
const selectedFromDropdownRef = useRef(false);
const prevSignalSourceRef = useRef(signalSource);
useEffect(() => {
setInputValue(currentMetricName || defaultValue || '');
}, [defaultValue, currentMetricName]);
useEffect(() => {
if (prevSignalSourceRef.current !== signalSource) {
prevSignalSourceRef.current = signalSource;
setSearchText('');
setInputValue('');
}
}, [signalSource]);
const debouncedValue = useDebounce(searchText, DEBOUNCE_DELAY);
const { isFetching, isError, data: listMetricsData } = useListMetrics(
{
searchText: debouncedValue,
limit: 100,
source: signalSource || undefined,
} as Record<string, unknown>,
{
query: {
keepPreviousData: false,
retry: 2,
},
},
);
const metrics = useMemo(() => listMetricsData?.data?.metrics ?? [], [
listMetricsData,
]);
useEffect(() => {
metricsRef.current = metrics;
}, [metrics]);
const optionsData = useMemo((): ExtendedSelectOption[] => {
if (!metrics.length) {
return [];
}
return metrics.map((metric) => ({
label: (
<OptionRenderer
label={metric.metricName}
value={metric.metricName}
dataType={DataTypes.Float64}
type={getAttributeType(metric) || ''}
/>
),
value: metric.metricName,
key: metric.metricName,
}));
}, [metrics]);
useEffect(() => {
const metricName = (query.aggregations?.[0] as MetricAggregation)?.metricName;
const hasAggregateAttributeType = query.aggregateAttribute?.type;
if (metricName && !hasAggregateAttributeType && metrics.length > 0) {
const found = metrics.find((m) => m.metricName === metricName);
if (found) {
onChange(
createAutocompleteData(found.metricName, getAttributeType(found)),
true,
);
}
}
}, [metrics, query.aggregations, query.aggregateAttribute?.type, onChange]);
const resolveMetricFromText = useCallback(
(text: string): BaseAutocompleteData => {
const found = metricsRef.current.find((m) => m.metricName === text);
if (found) {
return createAutocompleteData(found.metricName, getAttributeType(found));
}
return createAutocompleteData(text, '');
},
[],
);
const placeholder = useMemo(() => {
if (signalSource === 'meter') {
return 'Search for a meter metric...';
}
return 'Search for a metric...';
}, [signalSource]);
const handleChange = useCallback((value: string): void => {
setInputValue(value);
}, []);
const handleSearch = useCallback((value: string): void => {
setSearchText(value);
selectedFromDropdownRef.current = false;
}, []);
const handleSelect = useCallback(
(value: string): void => {
selectedFromDropdownRef.current = true;
const resolved = resolveMetricFromText(value);
onChange(resolved);
if (onSelect) {
onSelect(resolved);
}
setSearchText('');
},
[onChange, onSelect, resolveMetricFromText],
);
const handleBlur = useCallback(() => {
if (selectedFromDropdownRef.current) {
selectedFromDropdownRef.current = false;
return;
}
const typedValue = inputValue?.trim() || '';
if (typedValue && typedValue !== currentMetricName) {
onChange(resolveMetricFromText(typedValue));
} else if (!typedValue && currentMetricName) {
setInputValue(currentMetricName);
}
}, [inputValue, currentMetricName, onChange, resolveMetricFromText]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
const typedValue = inputValue?.trim() || '';
if (typedValue && typedValue !== currentMetricName) {
flushSync(() => {
onChange(resolveMetricFromText(typedValue));
});
}
}
};
document.addEventListener('keydown', handleKeyDown, true);
return (): void => {
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [inputValue, currentMetricName, onChange, resolveMetricFromText]);
return (
<AutoComplete
className="metric-name-selector"
getPopupContainer={popupContainer}
style={selectStyle}
filterOption={false}
placeholder={placeholder}
onSearch={handleSearch}
onChange={handleChange}
notFoundContent={
isFetching ? (
<Spin size="small" />
) : isError ? (
<Typography.Text type="danger" style={{ fontSize: 12 }}>
Failed to load metrics
</Typography.Text>
) : null
}
options={optionsData}
value={inputValue}
onBlur={handleBlur}
onSelect={handleSelect}
disabled={disabled}
/>
);
});

View File

@@ -1,2 +0,0 @@
export type { MetricNameSelectorProps } from './MetricNameSelector';
export { MetricNameSelector } from './MetricNameSelector';

View File

@@ -2,7 +2,6 @@ export { AggregatorFilter } from './AggregatorFilter';
export { BuilderUnitsFilter } from './BuilderUnitsFilter';
export { GroupByFilter } from './GroupByFilter';
export { HavingFilter } from './HavingFilter';
export { MetricNameSelector } from './MetricNameSelector';
export { OperatorsSelect } from './OperatorsSelect';
export { OrderByFilter } from './OrderByFilter';
export { ReduceToFilter } from './ReduceToFilter';

View File

@@ -33,6 +33,7 @@ import {
Unplug,
User,
UserPlus,
Users,
} from 'lucide-react';
import { SecondaryMenuItemKey, SidebarItem } from './sideNav.types';
@@ -326,6 +327,13 @@ export const settingsMenuItems: SidebarItem[] = [
isEnabled: false,
itemKey: 'members-sso',
},
{
key: ROUTES.MEMBERS_SETTINGS,
label: 'Members',
icon: <Users size={16} />,
isEnabled: false,
itemKey: 'members',
},
{
key: ROUTES.CUSTOM_DOMAIN_SETTINGS,
label: 'Custom Domain',

View File

@@ -257,9 +257,7 @@ function TimeSeriesView({
chartData[0]?.length === 0 &&
!isLoading &&
!isError &&
dataSource === DataSource.METRICS && (
<EmptyMetricsSearch hasQueryResult={data !== undefined} />
)}
dataSource === DataSource.METRICS && <EmptyMetricsSearch />}
{!isLoading &&
!isError &&

View File

@@ -153,6 +153,7 @@ export const routesToSkip = [
ROUTES.VERSION,
ROUTES.ALL_DASHBOARD,
ROUTES.ORG_SETTINGS,
ROUTES.MEMBERS_SETTINGS,
ROUTES.INGESTION_SETTINGS,
ROUTES.CUSTOM_DOMAIN_SETTINGS,
ROUTES.API_KEYS,

View File

@@ -248,12 +248,19 @@ export const useQueryOperations: UseQueryOperations = ({
);
const handleChangeAggregatorAttribute = useCallback(
(value: BaseAutocompleteData, isEditMode?: boolean): void => {
(
value: BaseAutocompleteData,
isEditMode?: boolean,
attributeKeys?: BaseAutocompleteData[],
): void => {
const newQuery: IBuilderQuery = {
...query,
aggregateAttribute: value,
};
const getAttributeKeyFromMetricName = (metricName: string): string =>
attributeKeys?.find((key) => key.key === metricName)?.type || '';
if (
newQuery.dataSource === DataSource.METRICS &&
entityVersion === ENTITY_VERSION_V4
@@ -304,7 +311,9 @@ export const useQueryOperations: UseQueryOperations = ({
// Get current metric info
const currentMetricType = newQuery.aggregateAttribute?.type || '';
const prevMetricType = previousMetricInfo?.type || '';
const prevMetricType = previousMetricInfo?.type
? previousMetricInfo.type
: getAttributeKeyFromMetricName(previousMetricInfo?.name || '');
// Check if metric type has changed by comparing with tracked previous values
const metricTypeChanged =
@@ -365,7 +374,7 @@ export const useQueryOperations: UseQueryOperations = ({
// Handled query with unknown metric to avoid 400 and 500 errors
// With metric value typed and not available then - time - 'avg', space - 'avg'
// If not typed - time - 'avg', space - 'sum'
// If not typed - time - 'rate', space - 'sum', op - 'count'
if (isEmpty(newQuery.aggregateAttribute?.type)) {
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
newQuery.aggregations = [
@@ -379,7 +388,7 @@ export const useQueryOperations: UseQueryOperations = ({
} else {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.AVG,
timeAggregation: MetricAggregateOperator.COUNT,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.SUM,
@@ -399,29 +408,6 @@ export const useQueryOperations: UseQueryOperations = ({
];
}
}
// Override with safe defaults when metric type is unknown to avoid 400/500 errors
if (isEmpty(newQuery.aggregateAttribute?.type)) {
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.AVG,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.AVG,
},
];
} else {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.AVG,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.SUM,
},
];
}
}
}
}

View File

@@ -20,7 +20,6 @@ export const useHandleExplorerTabChange = (): {
type: string,
querySearchParameters?: ICurrentQueryData,
redirectToUrl?: typeof ROUTES[keyof typeof ROUTES],
newTab?: boolean,
) => void;
} => {
const {
@@ -64,7 +63,6 @@ export const useHandleExplorerTabChange = (): {
type: string,
currentQueryData?: ICurrentQueryData,
redirectToUrl?: typeof ROUTES[keyof typeof ROUTES],
newTab?: boolean,
) => {
const newPanelType = type as PANEL_TYPES;
@@ -83,21 +81,13 @@ export const useHandleExplorerTabChange = (): {
[QueryParams.viewKey]: currentQueryData?.id || viewKey,
},
redirectToUrl,
undefined,
newTab,
);
} else {
redirectWithQueryBuilderData(
query,
{
[QueryParams.panelTypes]: newPanelType,
[QueryParams.viewName]: currentQueryData?.name || viewName,
[QueryParams.viewKey]: currentQueryData?.id || viewKey,
},
undefined,
undefined,
newTab,
);
redirectWithQueryBuilderData(query, {
[QueryParams.panelTypes]: newPanelType,
[QueryParams.viewName]: currentQueryData?.name || viewName,
[QueryParams.viewKey]: currentQueryData?.id || viewKey,
});
}
},
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewName, viewKey],

View File

@@ -54,7 +54,7 @@ export const stepIntervalUnchanged = {
{
metricName: '',
temporality: '',
timeAggregation: 'avg',
timeAggregation: 'count',
spaceAggregation: 'sum',
reduceTo: ReduceOperators.AVG,
},
@@ -177,7 +177,7 @@ export const replaceVariables = {
{
metricName: '',
temporality: '',
timeAggregation: 'avg',
timeAggregation: 'count',
spaceAggregation: 'sum',
reduceTo: ReduceOperators.AVG,
},
@@ -267,7 +267,7 @@ export const defaultOutput = {
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
temporality: '',
timeAggregation: 'avg',
timeAggregation: 'count',
},
],
filter: { expression: '' },
@@ -392,7 +392,7 @@ export const outputWithFunctions = {
{
metricName: '',
temporality: '',
timeAggregation: 'avg',
timeAggregation: 'count',
spaceAggregation: 'sum',
reduceTo: ReduceOperators.AVG,
},
@@ -429,7 +429,7 @@ export const outputWithFunctions = {
{
metricName: '',
temporality: '',
timeAggregation: 'avg',
timeAggregation: 'count',
spaceAggregation: 'sum',
reduceTo: ReduceOperators.AVG,
},

View File

@@ -4,7 +4,6 @@ import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useTimezone } from 'providers/Timezone';
import { TooltipProps } from '../types';
@@ -23,14 +22,6 @@ export default function Tooltip({
const isDarkMode = useIsDarkMode();
const [listHeight, setListHeight] = useState(0);
const tooltipContent = content ?? [];
const { timezone: userTimezone } = useTimezone();
const resolvedTimezone = useMemo(() => {
if (!timezone) {
return userTimezone.value;
}
return timezone.value;
}, [timezone, userTimezone]);
const headerTitle = useMemo(() => {
if (!showTooltipHeader) {
@@ -42,10 +33,10 @@ export default function Tooltip({
return null;
}
return dayjs(data[0][cursorIdx] * 1000)
.tz(resolvedTimezone)
.tz(timezone)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
}, [
resolvedTimezone,
timezone,
uPlotInstance.data,
uPlotInstance.cursor.idx,
showTooltipHeader,

View File

@@ -83,7 +83,7 @@ function createUPlotInstance(cursorIdx: number | null): uPlot {
function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
const defaultProps: TooltipTestProps = {
uPlotInstance: createUPlotInstance(null),
timezone: { value: 'UTC', name: 'UTC', offset: '0', searchIndex: '0' },
timezone: 'UTC',
content: [],
showTooltipHeader: true,
// TooltipRenderArgs (not used directly in component but required by type)

View File

@@ -92,7 +92,7 @@ export default function UPlotChart({
setPlotContextInitialState({
uPlotInstance: plot,
id: config.getId(),
widgetId: config.getWidgetId(),
shouldSaveSelectionPreference: config.getShouldSaveSelectionPreference(),
});

View File

@@ -84,7 +84,7 @@ const createMockConfig = (): UPlotConfigBuilder => {
hooks: {},
cursor: {},
}),
getId: jest.fn().mockReturnValue(undefined),
getWidgetId: jest.fn().mockReturnValue(undefined),
getShouldSaveSelectionPreference: jest.fn().mockReturnValue(false),
} as unknown) as UPlotConfigBuilder;
};

View File

@@ -1,5 +1,4 @@
import { ReactNode } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import uPlot from 'uplot';
@@ -62,7 +61,7 @@ export interface TooltipRenderArgs {
export interface BaseTooltipProps {
showTooltipHeader?: boolean;
timezone?: Timezone;
timezone: string;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
content?: TooltipContentItem[];

View File

@@ -74,21 +74,23 @@ export class UPlotConfigBuilder extends ConfigBuilder<
private tzDate: ((timestamp: number) => Date) | undefined;
private id: string;
private widgetId: string | undefined;
private onDragSelect: (startTime: number, endTime: number) => void;
constructor(args: ConfigBuilderProps) {
super(args);
constructor(args?: ConfigBuilderProps) {
super(args ?? {});
const {
id,
widgetId,
onDragSelect,
tzDate,
selectionPreferencesSource,
shouldSaveSelectionPreference,
stepInterval,
} = args ?? {};
this.id = id;
if (widgetId) {
this.widgetId = widgetId;
}
if (tzDate) {
this.tzDate = tzDate;
@@ -250,10 +252,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
*/
private getStoredVisibility(): SeriesVisibilityItem[] | null {
if (
this.id &&
this.widgetId &&
this.selectionPreferencesSource === SelectionPreferencesSource.LOCAL_STORAGE
) {
return getStoredSeriesVisibility(this.id);
return getStoredSeriesVisibility(this.widgetId);
}
return null;
}
@@ -376,10 +378,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
}
/**
* Get the id for the builder
* Get the widget id
*/
getId(): string {
return this.id;
getWidgetId(): string | undefined {
return this.widgetId;
}
/**

View File

@@ -1,3 +1,4 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
@@ -22,9 +23,6 @@ import {
* Path builders are static and shared across all instances of UPlotSeriesBuilder
*/
let builders: PathBuilders | null = null;
const DEFAULT_LINE_WIDTH = 2;
export const POINT_SIZE_FACTOR = 2.5;
export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
constructor(props: SeriesProps) {
super(props);
@@ -55,7 +53,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
const { lineWidth, lineStyle, lineCap, fillColor } = this.props;
const lineConfig: Partial<Series> = {
stroke: resolvedLineColor,
width: lineWidth ?? DEFAULT_LINE_WIDTH,
width: lineWidth ?? 2,
};
if (lineStyle === LineStyle.Dashed) {
@@ -68,9 +66,9 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
if (fillColor) {
lineConfig.fill = fillColor;
} else if (this.props.drawStyle === DrawStyle.Bar) {
} else if (this.props.panelType === PANEL_TYPES.BAR) {
lineConfig.fill = resolvedLineColor;
} else if (this.props.drawStyle === DrawStyle.Histogram) {
} else if (this.props.panelType === PANEL_TYPES.HISTOGRAM) {
lineConfig.fill = `${resolvedLineColor}40`;
}
@@ -139,19 +137,10 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
drawStyle,
showPoints,
} = this.props;
/**
* If pointSize is not provided, use the lineWidth * POINT_SIZE_FACTOR
* to determine the point size.
* POINT_SIZE_FACTOR is 2, so the point size will be 2x the line width.
*/
const resolvedPointSize =
pointSize ?? (lineWidth ?? DEFAULT_LINE_WIDTH) * POINT_SIZE_FACTOR;
const pointsConfig: Partial<Series.Points> = {
stroke: resolvedLineColor,
fill: resolvedLineColor,
size: resolvedPointSize,
size: !pointSize || pointSize < (lineWidth ?? 2) ? undefined : pointSize,
filter: pointsFilter || undefined,
};
@@ -242,7 +231,7 @@ function getPathBuilder({
throw new Error('Required uPlot path builders are not available');
}
if (drawStyle === DrawStyle.Bar || drawStyle === DrawStyle.Histogram) {
if (drawStyle === DrawStyle.Bar) {
const pathBuilders = uPlot.paths;
return getBarPathBuilder({
pathBuilders,

View File

@@ -1,3 +1,4 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot from 'uplot';
import {
@@ -42,27 +43,27 @@ describe('UPlotConfigBuilder', () => {
label: 'Requests',
colorMapping: {},
drawStyle: DrawStyle.Line,
panelType: PANEL_TYPES.TIME_SERIES,
...overrides,
});
it('returns correct save selection preference flag from constructor args', () => {
const builder = new UPlotConfigBuilder({
id: 'widget-123',
shouldSaveSelectionPreference: true,
});
expect(builder.getShouldSaveSelectionPreference()).toBe(true);
});
it('returns id from constructor args', () => {
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
it('returns widgetId from constructor args', () => {
const builder = new UPlotConfigBuilder({ widgetId: 'widget-123' });
expect(builder.getId()).toBe('widget-123');
expect(builder.getWidgetId()).toBe('widget-123');
});
it('sets tzDate from constructor and includes it in config', () => {
const tzDate = (ts: number): Date => new Date(ts);
const builder = new UPlotConfigBuilder({ id: 'widget-123', tzDate });
const builder = new UPlotConfigBuilder({ tzDate });
const config = builder.getConfig();
@@ -71,7 +72,7 @@ describe('UPlotConfigBuilder', () => {
it('does not call onDragSelect for click without drag (width === 0)', () => {
const onDragSelect = jest.fn();
const builder = new UPlotConfigBuilder({ id: 'widget-123', onDragSelect });
const builder = new UPlotConfigBuilder({ onDragSelect });
const config = builder.getConfig();
const setSelectHooks = config.hooks?.setSelect ?? [];
@@ -84,15 +85,14 @@ describe('UPlotConfigBuilder', () => {
// Simulate uPlot calling the hook
const setSelectHook = setSelectHooks[0];
expect(setSelectHook).toBeDefined();
setSelectHook?.(uplotInstance);
setSelectHook!(uplotInstance);
expect(onDragSelect).not.toHaveBeenCalled();
});
it('calls onDragSelect with start and end times in milliseconds for a drag selection', () => {
const onDragSelect = jest.fn();
const builder = new UPlotConfigBuilder({ id: 'widget-123', onDragSelect });
const builder = new UPlotConfigBuilder({ onDragSelect });
const config = builder.getConfig();
const setSelectHooks = config.hooks?.setSelect ?? [];
@@ -111,8 +111,7 @@ describe('UPlotConfigBuilder', () => {
} as unknown) as uPlot;
const setSelectHook = setSelectHooks[0];
expect(setSelectHook).toBeDefined();
setSelectHook?.(uplotInstance);
setSelectHook!(uplotInstance);
expect(onDragSelect).toHaveBeenCalledTimes(1);
// 100 and 110 seconds converted to milliseconds
@@ -120,7 +119,7 @@ describe('UPlotConfigBuilder', () => {
});
it('adds and removes hooks via addHook, and exposes them through getConfig', () => {
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const builder = new UPlotConfigBuilder();
const drawHook = jest.fn();
const remove = builder.addHook('draw', drawHook as uPlot.Hooks.Defs['draw']);
@@ -135,7 +134,7 @@ describe('UPlotConfigBuilder', () => {
});
it('adds axes, scales, and series and wires them into the final config', () => {
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const builder = new UPlotConfigBuilder();
// Add axis and scale
builder.addAxis({ scaleKey: 'y', label: 'Requests' });
@@ -171,7 +170,7 @@ describe('UPlotConfigBuilder', () => {
});
it('merges axis when addAxis is called twice with same scaleKey', () => {
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const builder = new UPlotConfigBuilder();
builder.addAxis({ scaleKey: 'y', label: 'Requests' });
builder.addAxis({ scaleKey: 'y', label: 'Updated Label', show: false });
@@ -184,7 +183,7 @@ describe('UPlotConfigBuilder', () => {
});
it('merges scale when addScale is called twice with same scaleKey', () => {
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const builder = new UPlotConfigBuilder();
builder.addScale({ scaleKey: 'y', min: 0 });
builder.addScale({ scaleKey: 'y', max: 100 });
@@ -205,7 +204,7 @@ describe('UPlotConfigBuilder', () => {
]);
const builder = new UPlotConfigBuilder({
id: 'widget-1',
widgetId: 'widget-1',
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
});
@@ -232,7 +231,7 @@ describe('UPlotConfigBuilder', () => {
]);
const builder = new UPlotConfigBuilder({
id: 'widget-1',
widgetId: 'widget-1',
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
});
@@ -270,7 +269,7 @@ describe('UPlotConfigBuilder', () => {
]);
const builder = new UPlotConfigBuilder({
id: 'widget-1',
widgetId: 'widget-1',
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
});
@@ -303,7 +302,7 @@ describe('UPlotConfigBuilder', () => {
]);
const builder = new UPlotConfigBuilder({
id: 'widget-dup',
widgetId: 'widget-dup',
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
});
@@ -330,7 +329,7 @@ describe('UPlotConfigBuilder', () => {
it('does not attempt to read stored visibility when using in-memory preferences', () => {
const builder = new UPlotConfigBuilder({
id: 'widget-1',
widgetId: 'widget-1',
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
});
@@ -345,7 +344,7 @@ describe('UPlotConfigBuilder', () => {
});
it('adds thresholds only once per scale key', () => {
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const builder = new UPlotConfigBuilder();
const thresholdsOptions = {
scaleKey: 'y',
@@ -363,7 +362,7 @@ describe('UPlotConfigBuilder', () => {
});
it('adds multiple thresholds when scale key is different', () => {
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const builder = new UPlotConfigBuilder();
const thresholdsOptions = {
scaleKey: 'y',
@@ -384,7 +383,7 @@ describe('UPlotConfigBuilder', () => {
});
it('merges cursor configuration with defaults instead of replacing them', () => {
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const builder = new UPlotConfigBuilder();
builder.setCursor({
drag: { setScale: false },
@@ -399,7 +398,7 @@ describe('UPlotConfigBuilder', () => {
describe('getCursorConfig', () => {
it('returns default cursor merged with custom cursor when no stepInterval', () => {
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const builder = new UPlotConfigBuilder();
builder.setCursor({
drag: { setScale: false },
@@ -413,7 +412,7 @@ describe('UPlotConfigBuilder', () => {
});
it('returns hover prox as DEFAULT_HOVER_PROXIMITY_VALUE when stepInterval is not set', () => {
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const builder = new UPlotConfigBuilder();
const cursorConfig = builder.getCursorConfig();
@@ -425,15 +424,15 @@ describe('UPlotConfigBuilder', () => {
const mockWidth = 100;
calculateWidthBasedOnStepIntervalMock.mockReturnValue(mockWidth);
const builder = new UPlotConfigBuilder({ id: 'widget-123', stepInterval });
const builder = new UPlotConfigBuilder({ stepInterval });
const cursorConfig = builder.getCursorConfig();
expect(typeof cursorConfig.hover?.prox).toBe('function');
const uPlotInstance = {} as uPlot;
const prox = cursorConfig.hover?.prox as ((u: uPlot) => number) | undefined;
expect(prox).toBeDefined();
const proxResult = prox ? prox(uPlotInstance) : NaN;
const proxResult = (cursorConfig.hover!.prox as (u: uPlot) => number)(
uPlotInstance,
);
expect(calculateWidthBasedOnStepIntervalMock).toHaveBeenCalledWith({
uPlotInstance,
@@ -444,7 +443,7 @@ describe('UPlotConfigBuilder', () => {
});
it('adds plugins and includes them in config', () => {
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const builder = new UPlotConfigBuilder();
const plugin: uPlot.Plugin = {
opts: (): void => {},
hooks: {},
@@ -459,7 +458,7 @@ describe('UPlotConfigBuilder', () => {
it('sets padding, legend, focus, select, tzDate, bands and includes them in config', () => {
const tzDate = (ts: number): Date => new Date(ts);
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const builder = new UPlotConfigBuilder();
const bands: uPlot.Band[] = [{ series: [1, 2], fill: (): string => '#000' }];
@@ -481,7 +480,7 @@ describe('UPlotConfigBuilder', () => {
});
it('does not include plugins when none added', () => {
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const builder = new UPlotConfigBuilder();
const config = builder.getConfig();
@@ -489,7 +488,7 @@ describe('UPlotConfigBuilder', () => {
});
it('does not include bands when empty', () => {
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const builder = new UPlotConfigBuilder();
const config = builder.getConfig();

View File

@@ -1,3 +1,4 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { themeColors } from 'constants/theme';
import uPlot from 'uplot';
@@ -8,7 +9,7 @@ import {
LineStyle,
VisibilityMode,
} from '../types';
import { POINT_SIZE_FACTOR, UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
import { UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
const createBaseProps = (
overrides: Partial<SeriesProps> = {},
@@ -18,6 +19,7 @@ const createBaseProps = (
colorMapping: {},
drawStyle: DrawStyle.Line,
isDarkMode: false,
panelType: PANEL_TYPES.TIME_SERIES,
...overrides,
});
@@ -135,6 +137,7 @@ describe('UPlotSeriesBuilder', () => {
const smallPointsBuilder = new UPlotSeriesBuilder(
createBaseProps({
lineWidth: 4,
pointSize: 2,
}),
);
const largePointsBuilder = new UPlotSeriesBuilder(
@@ -147,7 +150,7 @@ describe('UPlotSeriesBuilder', () => {
const smallConfig = smallPointsBuilder.getConfig();
const largeConfig = largePointsBuilder.getConfig();
expect(smallConfig.points?.size).toBe(4 * POINT_SIZE_FACTOR); // should be lineWidth * POINT_SIZE_FACTOR, when pointSize is not provided
expect(smallConfig.points?.size).toBeUndefined();
expect(largeConfig.points?.size).toBe(4);
});

View File

@@ -34,7 +34,7 @@ export enum SelectionPreferencesSource {
* Props for configuring the uPlot config builder
*/
export interface ConfigBuilderProps {
id: string;
widgetId?: string;
onDragSelect?: (startTime: number, endTime: number) => void;
tzDate?: uPlot.LocalDateFromUnix;
selectionPreferencesSource?: SelectionPreferencesSource;
@@ -112,7 +112,6 @@ export enum DrawStyle {
Line = 'line',
Points = 'points',
Bar = 'bar',
Histogram = 'histogram',
}
export enum LineInterpolation {
@@ -169,6 +168,7 @@ export interface PointsConfig {
export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
scaleKey: string;
label?: string;
panelType: PANEL_TYPES;
colorMapping: Record<string, string>;
drawStyle: DrawStyle;
pathBuilder?: Series.PathBuilder;

View File

@@ -13,7 +13,7 @@ import { updateSeriesVisibilityToLocalStorage } from 'container/DashboardContain
import type uPlot from 'uplot';
export interface PlotContextInitialState {
uPlotInstance: uPlot | null;
id?: string;
widgetId?: string;
shouldSaveSelectionPreference?: boolean;
}
export interface IPlotContext {
@@ -31,17 +31,17 @@ export const PlotContextProvider = ({
}: PropsWithChildren): JSX.Element => {
const uPlotInstanceRef = useRef<uPlot | null>(null);
const activeSeriesIndex = useRef<number | undefined>(undefined);
const idRef = useRef<string | undefined>(undefined);
const widgetIdRef = useRef<string | undefined>(undefined);
const shouldSavePreferencesRef = useRef<boolean>(false);
const setPlotContextInitialState = useCallback(
({
uPlotInstance,
id,
widgetId,
shouldSaveSelectionPreference,
}: PlotContextInitialState): void => {
uPlotInstanceRef.current = uPlotInstance;
idRef.current = id;
widgetIdRef.current = widgetId;
activeSeriesIndex.current = undefined;
shouldSavePreferencesRef.current = !!shouldSaveSelectionPreference;
},
@@ -50,7 +50,7 @@ export const PlotContextProvider = ({
const syncSeriesVisibilityToLocalStorage = useCallback((): void => {
const plot = uPlotInstanceRef.current;
if (!plot || !idRef.current) {
if (!plot || !widgetIdRef.current) {
return;
}
@@ -61,7 +61,7 @@ export const PlotContextProvider = ({
}),
);
updateSeriesVisibilityToLocalStorage(idRef.current, seriesVisibility);
updateSeriesVisibilityToLocalStorage(widgetIdRef.current, seriesVisibility);
}, []);
const onToggleSeriesVisibility = useCallback(
@@ -84,7 +84,7 @@ export const PlotContextProvider = ({
show: isReset || currentSeriesIndex === seriesIndex,
});
});
if (idRef.current && shouldSavePreferencesRef.current) {
if (widgetIdRef.current && shouldSavePreferencesRef.current) {
syncSeriesVisibilityToLocalStorage();
}
});
@@ -104,7 +104,7 @@ export const PlotContextProvider = ({
return;
}
plot.setSeries(seriesIndex, { show: !series.show });
if (idRef.current && shouldSavePreferencesRef.current) {
if (widgetIdRef.current && shouldSavePreferencesRef.current) {
syncSeriesVisibilityToLocalStorage();
}
},

View File

@@ -32,13 +32,13 @@ const createMockPlot = (series: MockSeries[] = []): uPlot =>
interface TestComponentProps {
plot?: uPlot;
id?: string;
widgetId?: string;
shouldSaveSelectionPreference?: boolean;
}
const TestComponent = ({
plot,
id,
widgetId,
shouldSaveSelectionPreference,
}: TestComponentProps): JSX.Element => {
const {
@@ -49,13 +49,17 @@ const TestComponent = ({
onFocusSeries,
} = usePlotContext();
const handleInit = (): void => {
if (!plot || !id || typeof shouldSaveSelectionPreference !== 'boolean') {
if (
!plot ||
!widgetId ||
typeof shouldSaveSelectionPreference !== 'boolean'
) {
return;
}
setPlotContextInitialState({
uPlotInstance: plot,
id,
widgetId,
shouldSaveSelectionPreference,
});
};
@@ -144,7 +148,11 @@ describe('PlotContext', () => {
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
<TestComponent
plot={plot}
widgetId="widget-123"
shouldSaveSelectionPreference
/>
</PlotContextProvider>,
);
@@ -191,7 +199,7 @@ describe('PlotContext', () => {
<PlotContextProvider>
<TestComponent
plot={plot}
id="widget-visibility"
widgetId="widget-visibility"
shouldSaveSelectionPreference
/>
</PlotContextProvider>,
@@ -232,7 +240,7 @@ describe('PlotContext', () => {
<PlotContextProvider>
<TestComponent
plot={plot}
id="widget-reset"
widgetId="widget-reset"
shouldSaveSelectionPreference
/>
</PlotContextProvider>,
@@ -282,7 +290,7 @@ describe('PlotContext', () => {
<PlotContextProvider>
<TestComponent
plot={plot}
id="widget-toggle"
widgetId="widget-toggle"
shouldSaveSelectionPreference
/>
</PlotContextProvider>,
@@ -308,7 +316,7 @@ describe('PlotContext', () => {
<PlotContextProvider>
<TestComponent
plot={plot}
id="widget-missing-series"
widgetId="widget-missing-series"
shouldSaveSelectionPreference
/>
</PlotContextProvider>,
@@ -333,7 +341,7 @@ describe('PlotContext', () => {
<PlotContextProvider>
<TestComponent
plot={plot}
id="widget-no-persist"
widgetId="widget-no-persist"
shouldSaveSelectionPreference={false}
/>
</PlotContextProvider>,
@@ -371,7 +379,7 @@ describe('PlotContext', () => {
<PlotContextProvider>
<TestComponent
plot={plot}
id="widget-focus"
widgetId="widget-focus"
shouldSaveSelectionPreference={false}
/>
</PlotContextProvider>,

View File

@@ -40,7 +40,7 @@ class TestConfigBuilder extends UPlotConfigBuilder {
type ConfigMock = TestConfigBuilder;
function createConfigMock(): ConfigMock {
return new TestConfigBuilder({ id: 'test-widget' });
return new TestConfigBuilder();
}
function getHandler(config: ConfigMock, hookName: string): HookHandler {

View File

@@ -252,23 +252,4 @@
}
}
}
.alert-details-v2 {
.tabs-and-filters {
.ant-tabs {
.ant-tabs-tab {
.ant-tabs-tab-btn {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
color: var(--text-ink-400) !important;
&[aria-selected='true'] {
background: var(--bg-vanilla-200);
color: var(--text-robin-500) !important;
}
}
}
}
}
}
}

View File

@@ -0,0 +1,7 @@
import MembersSettingsContainer from 'container/MembersSettings/MembersSettings';
function MembersSettings(): JSX.Element {
return <MembersSettingsContainer />;
}
export default MembersSettings;

View File

@@ -24,25 +24,3 @@
}
}
}
.lightMode {
.service-route-tab {
.ant-tabs-nav {
&::before {
border-bottom: 1px solid var(--bg-vanilla-300);
}
.ant-tabs-nav-wrap {
.ant-tabs-nav-list {
.ant-tabs-tab {
color: var(--text-ink-400);
}
.ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
color: var(--text-robin-500);
}
}
}
}
}
}

View File

@@ -83,6 +83,7 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.INGESTION_SETTINGS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.SHORTCUTS
? true
: item.isEnabled,
@@ -112,6 +113,7 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.INGESTION_SETTINGS
? true
: item.isEnabled,
@@ -137,6 +139,7 @@ function SettingsPage(): JSX.Element {
isEnabled:
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.ROLES_SETTINGS
? true
: item.isEnabled,

View File

@@ -27,8 +27,10 @@ import {
Plus,
Shield,
User,
Users,
} from 'lucide-react';
import ChannelsEdit from 'pages/ChannelsEdit';
import MembersSettings from 'pages/MembersSettings';
import Shortcuts from 'pages/Shortcuts';
export const organizationSettings = (t: TFunction): RouteTabProps['routes'] => [
@@ -150,6 +152,19 @@ export const billingSettings = (t: TFunction): RouteTabProps['routes'] => [
},
];
export const membersSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: MembersSettings,
name: (
<div className="periscope-tab">
<Users size={16} /> {t('routes:members').toString()}
</div>
),
route: ROUTES.MEMBERS_SETTINGS,
key: ROUTES.MEMBERS_SETTINGS,
},
];
export const rolesSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: RolesSettings,

View File

@@ -12,6 +12,7 @@ import {
generalSettings,
ingestionSettings,
keyboardShortcuts,
membersSettings,
multiIngestionSettings,
mySettings,
organizationSettings,
@@ -60,7 +61,7 @@ export const getRoutes = (
settings.push(...alertChannels(t));
if (isAdmin) {
settings.push(...apiKeys(t));
settings.push(...apiKeys(t), ...membersSettings(t));
}
if ((isCloudUser || isEnterpriseSelfHostedUser) && isAdmin) {

View File

@@ -947,7 +947,6 @@ export function QueryBuilderProvider({
searchParams?: Record<string, unknown>,
redirectingUrl?: typeof ROUTES[keyof typeof ROUTES],
shouldNotStringify?: boolean,
newTab?: boolean,
) => {
const queryType =
!query.queryType || !Object.values(EQueryType).includes(query.queryType)
@@ -1014,7 +1013,7 @@ export function QueryBuilderProvider({
? `${redirectingUrl}?${urlQuery}`
: `${location.pathname}?${urlQuery}`;
safeNavigate(generatedUrl, { newTab });
safeNavigate(generatedUrl);
},
[location.pathname, safeNavigate, urlQuery],
);

View File

@@ -72,6 +72,7 @@ export type UseQueryOperations = (
handleChangeAggregatorAttribute: (
value: BaseAutocompleteData,
isEditMode?: boolean,
attributeKeys?: BaseAutocompleteData[],
) => void;
handleChangeDataSource: (newSource: DataSource) => void;
handleDeleteQuery: () => void;

View File

@@ -278,7 +278,6 @@ export type QueryBuilderContextType = {
searchParams?: Record<string, unknown>,
redirectToUrl?: typeof ROUTES[keyof typeof ROUTES],
shallStringify?: boolean,
newTab?: boolean,
) => void;
handleRunQuery: () => void;
resetQuery: (newCurrentQuery?: QueryState) => void;

View File

@@ -98,6 +98,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLES_SETTINGS: ['ADMIN'],
MEMBERS_SETTINGS: ['ADMIN'],
BILLING: ['ADMIN'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@@ -4111,7 +4111,7 @@
aria-hidden "^1.1.1"
react-remove-scroll "2.5.4"
"@radix-ui/react-dialog@^1.1.11", "@radix-ui/react-dialog@^1.1.6":
"@radix-ui/react-dialog@^1.1.1", "@radix-ui/react-dialog@^1.1.11", "@radix-ui/react-dialog@^1.1.6":
version "1.1.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz#1de3d7a7e9a17a9874d29c07f5940a18a119b632"
integrity sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==
@@ -5066,6 +5066,35 @@
resolved "https://registry.yarnpkg.com/@signozhq/design-tokens/-/design-tokens-2.1.1.tgz#9c36d433fd264410713cc0c5ebdd75ce0ebecba3"
integrity sha512-SdziCHg5Lwj+6oY6IRUPplaKZ+kTHjbrlhNj//UoAJ8aQLnRdR2F/miPzfSi4vrYw88LtXxNA9J9iJyacCp37A==
"@signozhq/dialog@0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@signozhq/dialog/-/dialog-0.0.2.tgz#55bd8e693f76325fda9aabe3197350e0adc163c4"
integrity sha512-YT5t3oZpGkAuWptTqhCgVtLjxsRQrEIrQHFoXpP9elM1+O4TS9WHr+07BLQutOVg6u9n9pCvW3OYf0SCETkDVQ==
dependencies:
"@radix-ui/react-dialog" "^1.1.11"
"@radix-ui/react-icons" "^1.3.0"
"@radix-ui/react-slot" "^1.1.0"
class-variance-authority "^0.7.0"
clsx "^2.1.1"
lucide-react "^0.445.0"
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/drawer@0.0.4":
version "0.0.4"
resolved "https://registry.yarnpkg.com/@signozhq/drawer/-/drawer-0.0.4.tgz#7c6e6779602113f55df8a55076e68b9cc13c7d79"
integrity sha512-m/shStl5yVPjHjrhDAh3EeKqqTtMmZUBVlgJPUGgoNV3sFsuN6JNaaAtEJI8cQBWkbEEiHLWKVkL/vhbQ7YrUg==
dependencies:
"@radix-ui/react-dialog" "^1.1.11"
"@radix-ui/react-icons" "^1.3.0"
"@radix-ui/react-slot" "^1.1.0"
class-variance-authority "^0.7.0"
clsx "^2.1.1"
lucide-react "^0.445.0"
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
vaul "^1.1.2"
"@signozhq/icons@0.1.0", "@signozhq/icons@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@signozhq/icons/-/icons-0.1.0.tgz#00dfb430dbac423bfff715876f91a7b8a72509e4"
@@ -19823,6 +19852,13 @@ vary@~1.1.2:
resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz"
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
vaul@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vaul/-/vaul-1.1.2.tgz#c959f8b9dc2ed4f7d99366caee433fbef91f5ba9"
integrity sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==
dependencies:
"@radix-ui/react-dialog" "^1.1.1"
vfile-location@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-4.1.0.tgz#69df82fb9ef0a38d0d02b90dd84620e120050dd0"

View File

@@ -14,7 +14,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrymeter"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
@@ -57,81 +56,11 @@ func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetr
}
}
// TODO(srikanthccv): use metadata store to fetch metric metadata
func (m *module) ListMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if params.Source == "meter" {
return m.listMeterMetrics(ctx, params)
}
return m.listMetrics(ctx, orgID, params)
}
func (m *module) listMeterMetrics(ctx context.Context, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"metric_name",
"any(description) AS description",
"any(type) AS metric_type",
"any(unit) AS metric_unit",
"argMax(temporality, unix_milli) AS temporality",
"any(is_monotonic) AS is_monotonic",
)
sb.From(fmt.Sprintf("%s.%s", telemetrymeter.DBName, telemetrymeter.SamplesTableName))
if params.Start != nil && params.End != nil {
sb.Where(sb.Between("unix_milli", *params.Start, *params.End))
}
if params.Search != "" {
searchLower := strings.ToLower(params.Search)
searchLower = strings.ReplaceAll(searchLower, "%", "\\%")
searchLower = strings.ReplaceAll(searchLower, "_", "\\_")
sb.Where(sb.Like("lower(metric_name)", fmt.Sprintf("%%%s%%", searchLower)))
}
sb.GroupBy("metric_name")
sb.OrderBy("metric_name ASC")
sb.Limit(params.Limit)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to list meter metrics")
}
defer rows.Close()
metrics := make([]metricsexplorertypes.ListMetric, 0)
for rows.Next() {
var metric metricsexplorertypes.ListMetric
if err := rows.Scan(
&metric.MetricName,
&metric.Description,
&metric.MetricType,
&metric.MetricUnit,
&metric.Temporality,
&metric.IsMonotonic,
); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan meter metric")
}
metrics = append(metrics, metric)
}
if err := rows.Err(); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error iterating meter metrics")
}
return &metricsexplorertypes.ListMetricsResponse{
Metrics: metrics,
}, nil
}
func (m *module) listMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("DISTINCT metric_name")
@@ -786,8 +715,9 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
Logger: m.logger,
FieldMapper: m.fieldMapper,
ConditionBuilder: m.condBuilder,
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
FieldKeys: keys,
FullTextColumn: &telemetrytypes.TelemetryFieldKey{
Name: "labels"},
FieldKeys: keys,
}
startNs := querybuilder.ToNanoSecs(uint64(startMillis))

View File

@@ -1631,10 +1631,8 @@ func (t *telemetryMetaStore) FetchTemporalityAndTypeMulti(ctx context.Context, q
if err != nil {
return nil, nil, err
}
meterMetricsTemporality, meterMetricsTypes, err := t.fetchMeterSourceMetricsTemporalityAndType(ctx, metricNames...)
if err != nil {
return nil, nil, err
}
// TODO: return error after table migration are run
meterMetricsTemporality, meterMetricsTypes, _ := t.fetchMeterSourceMetricsTemporalityAndType(ctx, metricNames...)
// For metrics not found in the database, set to Unknown
for _, metricName := range metricNames {
@@ -1730,7 +1728,6 @@ func (t *telemetryMetaStore) fetchMeterSourceMetricsTemporalityAndType(ctx conte
"metric_name",
"argMax(temporality, unix_milli) as temporality",
"any(type) AS type",
"any(is_monotonic) as is_monotonic",
).From(t.meterDBName + "." + t.meterFieldsTblName)
// Filter by metric names (in the temporality column due to data mix-up)

View File

@@ -301,7 +301,6 @@ type ListMetricsParams struct {
End *int64 `query:"end"`
Limit int `query:"limit"`
Search string `query:"searchText"`
Source string `query:"source"`
}
// Validate ensures ListMetricsParams contains acceptable values.

View File

@@ -447,7 +447,7 @@ type MetricAggregation struct {
// space aggregation to apply to the query
SpaceAggregation metrictypes.SpaceAggregation `json:"spaceAggregation"`
// param for space aggregation if needed
ComparisonSpaceAggregationParam *metrictypes.ComparisonSpaceAggregationParam `json:"comparisonSpaceAggregationParam,omitempty"`
ComparisonSpaceAggregationParam *metrictypes.ComparisonSpaceAggregationParam `json:"comparisonSpaceAggregationParam"`
// table hints to use for the query
TableHints *metrictypes.MetricTableHints `json:"-"`
// value filter to apply to the query

View File

@@ -15,7 +15,6 @@ pytest_plugins = [
"fixtures.logs",
"fixtures.traces",
"fixtures.metrics",
"fixtures.meter",
"fixtures.driver",
"fixtures.idp",
"fixtures.idputils",

View File

@@ -1,121 +0,0 @@
import hashlib
import json
from datetime import datetime, timedelta
from typing import Any, Callable, Generator, List
import numpy as np
import pytest
from fixtures import types
class MeterSample:
temporality: str
metric_name: str
description: str
unit: str
type: str
is_monotonic: bool
labels: str
fingerprint: np.uint64
unix_milli: np.int64
value: np.float64
def __init__(
self,
metric_name: str,
labels: dict[str, str],
timestamp: datetime,
value: float,
temporality: str = "Delta",
description: str = "",
unit: str = "",
type_: str = "Sum",
is_monotonic: bool = True,
) -> None:
self.temporality = temporality
self.metric_name = metric_name
self.description = description
self.unit = unit
self.type = type_
self.is_monotonic = is_monotonic
self.labels = json.dumps(labels, separators=(",", ":"))
self.unix_milli = np.int64(int(timestamp.timestamp() * 1e3))
self.value = np.float64(value)
fingerprint_str = metric_name + self.labels
self.fingerprint = np.uint64(
int(hashlib.md5(fingerprint_str.encode()).hexdigest()[:16], 16)
)
def to_samples_row(self) -> list:
return [
self.temporality,
self.metric_name,
self.description,
self.unit,
self.type,
self.is_monotonic,
self.labels,
self.fingerprint,
self.unix_milli,
self.value,
]
def make_meter_samples(
metric_name: str,
labels: dict[str, str],
now: datetime,
count: int = 60,
base_value: float = 100.0,
**kwargs,
) -> List[MeterSample]:
samples = []
for i in range(count):
ts = now - timedelta(minutes=count - i)
samples.append(
MeterSample(
metric_name=metric_name,
labels=labels,
timestamp=ts,
value=base_value + i,
**kwargs,
)
)
return samples
@pytest.fixture(name="insert_meter_samples", scope="function")
def insert_meter_samples(
clickhouse: types.TestContainerClickhouse,
) -> Generator[Callable[[List[MeterSample]], None], Any, None]:
def _insert_meter_samples(samples: List[MeterSample]) -> None:
if len(samples) == 0:
return
clickhouse.conn.insert(
database="signoz_meter",
table="distributed_samples",
column_names=[
"temporality",
"metric_name",
"description",
"unit",
"type",
"is_monotonic",
"labels",
"fingerprint",
"unix_milli",
"value",
],
data=[s.to_samples_row() for s in samples],
)
yield _insert_meter_samples
cluster = clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
for table in ["samples", "samples_agg_1d"]:
clickhouse.conn.query(
f"TRUNCATE TABLE signoz_meter.{table} ON CLUSTER '{cluster}' SYNC"
)

View File

@@ -54,7 +54,6 @@ def build_builder_query(
*,
comparisonSpaceAggregationParam: Optional[Dict] = None,
temporality: Optional[str] = None,
source: Optional[str] = None,
step_interval: int = DEFAULT_STEP_INTERVAL,
group_by: Optional[List[str]] = None,
filter_expression: Optional[str] = None,
@@ -74,14 +73,10 @@ def build_builder_query(
"stepInterval": step_interval,
"disabled": disabled,
}
if source:
spec["source"] = source
if temporality:
spec["aggregations"][0]["temporality"] = temporality
if comparisonSpaceAggregationParam:
spec["aggregations"][0][
"comparisonSpaceAggregationParam"
] = comparisonSpaceAggregationParam
spec["aggregations"][0]["comparisonSpaceAggregationParam"] = comparisonSpaceAggregationParam
if group_by:
spec["groupBy"] = [
{

View File

@@ -2,6 +2,7 @@
Look at the histogram_data_1h.jsonl file for the relevant data
"""
import random
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
from typing import Callable, List
@@ -21,7 +22,6 @@ from fixtures.utils import get_testdata_file_path
FILE = get_testdata_file_path("histogram_data_1h.jsonl")
@pytest.mark.parametrize(
"threshold, operator, first_value, last_value",
[
@@ -29,22 +29,12 @@ FILE = get_testdata_file_path("histogram_data_1h.jsonl")
(100, "<=", 1.1, 6.9),
(7500, "<=", 16.75, 74.75),
(8000, "<=", 17, 75),
(
80000,
"<=",
17,
75,
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(80000, "<=", 17, 75), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(1000, ">", 7, 7),
(100, ">", 16.9, 69.1),
(7500, ">", 1.25, 1.25),
(8000, ">", 1, 1),
(
80000,
">",
1,
1,
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(80000, ">", 1, 1), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
],
)
def test_histogram_count_for_one_endpoint(
@@ -75,7 +65,10 @@ def test_histogram_count_for_one_endpoint(
metric_name,
"increase",
"count",
comparisonSpaceAggregationParam={"threshold": threshold, "operator": operator},
comparisonSpaceAggregationParam={
"threshold": threshold,
"operator": operator
},
filter_expression='endpoint = "/health"',
)
@@ -88,7 +81,6 @@ def test_histogram_count_for_one_endpoint(
assert result_values[0]["value"] == first_value
assert result_values[-1]["value"] == last_value
@pytest.mark.parametrize(
"threshold, operator, first_value, last_value",
[
@@ -96,22 +88,12 @@ def test_histogram_count_for_one_endpoint(
(100, "<=", 2.2, 13.8),
(7500, "<=", 33.5, 149.5),
(8000, "<=", 34, 150),
(
80000,
"<=",
34,
150,
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(80000, "<=", 34, 150), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(1000, ">", 14, 14),
(100, ">", 33.8, 138.2),
(7500, ">", 2.5, 2.5),
(8000, ">", 2, 2),
(
80000,
">",
2,
2,
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(80000, ">", 2, 2), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
],
)
def test_histogram_count_for_one_service(
@@ -142,7 +124,10 @@ def test_histogram_count_for_one_service(
metric_name,
"increase",
"count",
comparisonSpaceAggregationParam={"threshold": threshold, "operator": operator},
comparisonSpaceAggregationParam={
"threshold": threshold,
"operator": operator
},
filter_expression='service = "api"',
)
@@ -155,7 +140,6 @@ def test_histogram_count_for_one_service(
assert result_values[0]["value"] == first_value
assert result_values[-1]["value"] == last_value
@pytest.mark.parametrize(
"threshold, operator, zeroth_value, first_value, last_value",
[
@@ -163,24 +147,12 @@ def test_histogram_count_for_one_service(
(100, "<=", 1234.5, 1.1, 6.9),
(7500, "<=", 12345, 16.75, 74.75),
(8000, "<=", 12345, 17, 75),
(
80000,
"<=",
12345,
17,
75,
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(80000, "<=", 12345, 17, 75), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(1000, ">", 0, 7, 7),
(100, ">", 11110.5, 16.9, 69.1),
(7500, ">", 0, 1.25, 1.25),
(8000, ">", 0, 1, 1),
(
80000,
">",
0,
1,
1,
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(80000, ">", 0, 1, 1), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
],
)
def test_histogram_count_for_delta_service(
@@ -212,7 +184,10 @@ def test_histogram_count_for_delta_service(
metric_name,
"increase",
"count",
comparisonSpaceAggregationParam={"threshold": threshold, "operator": operator},
comparisonSpaceAggregationParam={
"threshold": threshold,
"operator": operator
},
filter_expression='service = "web"',
)
@@ -221,16 +196,11 @@ def test_histogram_count_for_delta_service(
data = response.json()
result_values = sorted(get_series_values(data, "A"), key=lambda x: x["timestamp"])
assert (
len(result_values) == 60
) ## in delta, the value at 10:01 will also be reported
assert len(result_values) == 60 ## in delta, the value at 10:01 will also be reported
assert result_values[0]["value"] == zeroth_value
assert (
result_values[1]["value"] == first_value
) ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
assert result_values[1]["value"] == first_value ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
assert result_values[-1]["value"] == last_value
@pytest.mark.parametrize(
"threshold, operator, zeroth_value, first_value, last_value",
[
@@ -275,7 +245,10 @@ def test_histogram_count_for_all_services(
metric_name,
"increase",
"count",
comparisonSpaceAggregationParam={"threshold": threshold, "operator": operator},
comparisonSpaceAggregationParam={
"threshold": threshold,
"operator": operator
},
## no services filter, this tests for multitemporality handling as well
)
@@ -284,16 +257,11 @@ def test_histogram_count_for_all_services(
data = response.json()
result_values = sorted(get_series_values(data, "A"), key=lambda x: x["timestamp"])
assert (
len(result_values) == 60
) ## in delta, the value at 10:01 will also be reported
assert len(result_values) == 60 ## in delta, the value at 10:01 will also be reported
assert result_values[0]["value"] == zeroth_value
assert (
result_values[1]["value"] == first_value
) ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
assert result_values[1]["value"] == first_value ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
assert result_values[-1]["value"] == last_value
def test_histogram_count_no_param(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
@@ -340,26 +308,8 @@ def test_histogram_count_no_param(
set(le_buckets.keys()) == expected_buckets
), f"Expected endpoints {expected_buckets}, got {set(le_buckets.keys())}"
first_values = {
"1000": 33,
"1500": 36,
"2000": 39,
"4000": 42,
"5000": 45,
"6000": 48,
"8000": 51,
"+Inf": 54,
}
last_values = {
"1000": 207,
"1500": 210,
"2000": 213,
"4000": 216,
"5000": 219,
"6000": 222,
"8000": 225,
"+Inf": 228,
}
first_values = {"1000": 33, "1500": 36, "2000": 39, "4000": 42, "5000": 45, "6000": 48, "8000": 51, "+Inf": 54}
last_values = {"1000": 207, "1500": 210, "2000": 213, "4000": 216, "5000": 219, "6000": 222, "8000": 225, "+Inf": 228}
for le, values in le_buckets.items():
assert len(values) == 60
@@ -368,7 +318,5 @@ def test_histogram_count_no_param(
v["value"] >= 0
), f"Count for {le} should not be negative: {v['value']}"
assert values[0]["value"] == 12345
assert (
values[1]["value"] == first_values[le]
) ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
assert values[-1]["value"] == last_values[le]
assert values[1]["value"] == first_values[le] ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
assert values[-1]["value"] == last_values[le]

View File

@@ -1,3 +1,4 @@
import random
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
from typing import Callable, List
@@ -9,6 +10,7 @@ from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.querier import (
build_builder_query,
get_all_series,
get_series_values,
make_query_request,
)
@@ -16,7 +18,6 @@ from fixtures.utils import get_testdata_file_path
FILE = get_testdata_file_path("gauge_data_1h.jsonl")
@pytest.mark.parametrize(
"time_agg, space_agg, service, num_elements, start_val, first_val, twentieth_min_val, after_twentieth_min_val",
[
@@ -49,7 +50,7 @@ def test_for_one_service(
start_val: float,
first_val: float,
twentieth_min_val: float,
after_twentieth_min_val: float, ## web service has a gap of 10 mins after the 20th minute
after_twentieth_min_val: float ## web service has a gap of 10 mins after the 20th minute
) -> None:
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
@@ -83,7 +84,6 @@ def test_for_one_service(
assert result_values[19]["value"] == twentieth_min_val
assert result_values[20]["value"] == after_twentieth_min_val
@pytest.mark.parametrize(
"time_agg, space_agg, start_val, first_val, twentieth_min_val, twenty_first_min_val, thirty_first_min_val",
[
@@ -105,8 +105,8 @@ def test_for_multiple_aggregations(
start_val: float,
first_val: float,
twentieth_min_val: float,
twenty_first_min_val: float, ## web service has a gap of 10 mins after the 20th minute
thirty_first_min_val: float,
twenty_first_min_val: float, ## web service has a gap of 10 mins after the 20th minute
thirty_first_min_val: float
) -> None:
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
@@ -138,4 +138,4 @@ def test_for_multiple_aggregations(
assert result_values[1]["value"] == first_val
assert result_values[19]["value"] == twentieth_min_val
assert result_values[20]["value"] == twenty_first_min_val
assert result_values[30]["value"] == thirty_first_min_val
assert result_values[30]["value"] == thirty_first_min_val

View File

@@ -53,9 +53,7 @@ def test_rate_with_steady_values_and_reset(
data = response.json()
result_values = sorted(get_series_values(data, "A"), key=lambda x: x["timestamp"])
assert (
len(result_values) == 60
) ## total 61 minutes covered, and 30th minute is missing
assert len(result_values) == 60 ## total 61 minutes covered, and 30th minute is missing
assert (
result_values[30]["value"] == 0.0333
) # reset happens and [30] is for 31st minute. 2/60 cuz delta divides by step interval
@@ -63,7 +61,9 @@ def test_rate_with_steady_values_and_reset(
result_values[31]["value"] == 0.133
) # i.e 8/60 i.e 31st to 32nd minute changes
count_of_steady_rate = sum(1 for v in result_values if v["value"] == 0.0833)
assert count_of_steady_rate == 58 # 1 reset + 1 high rate are excluded
assert (
count_of_steady_rate == 58
) # 1 reset + 1 high rate are excluded
# All rates should be non-negative (stale periods = 0 rate)
for v in result_values:
assert v["value"] >= 0, f"Rate should not be negative: {v['value']}"

View File

@@ -1,110 +0,0 @@
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
from typing import Callable, List
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.meter import MeterSample, make_meter_samples
from fixtures.querier import (
build_builder_query,
get_series_values,
make_query_request,
)
def test_query_range_cost_meter(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_meter_samples: Callable[[List[MeterSample]], None],
) -> None:
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
metric_name = "signoz_cost_test_query_range"
labels = {"service": "test-service", "environment": "production"}
samples = make_meter_samples(
metric_name,
labels,
now,
count=60,
base_value=100.0,
temporality="Delta",
type_="Sum",
is_monotonic=True,
)
insert_meter_samples(samples)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query(
"A",
metric_name,
"sum",
"sum",
source="meter",
temporality="delta",
)
response = make_query_request(signoz, token, start_ms, end_ms, [query])
assert response.status_code == HTTPStatus.OK
data = response.json()
result_values = get_series_values(data, "A")
assert len(result_values) > 0, f"Expected non-empty results, got: {data}"
for val in result_values:
assert val["value"] >= 0, f"Expected non-negative value, got: {val['value']}"
def test_list_meter_metric_names(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_meter_samples: Callable[[List[MeterSample]], None],
) -> None:
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
metric_name = "cost_test_list_metrics"
labels = {"service": "billing-service"}
samples = make_meter_samples(
metric_name,
labels,
now,
count=5,
base_value=50.0,
temporality="Delta",
type_="Sum",
is_monotonic=True,
)
insert_meter_samples(samples)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/metrics"),
params={
"start": start_ms,
"end": end_ms,
"limit": 100,
"searchText": "cost_test_list",
"source": "meter",
},
headers={"authorization": f"Bearer {token}"},
timeout=30,
)
assert response.status_code == HTTPStatus.OK
data = response.json()
metrics = data.get("data", {}).get("metrics", [])
metric_names = [m["metricName"] for m in metrics]
assert (
metric_name in metric_names
), f"Expected {metric_name} in metric names, got: {metric_names}"