mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-02 23:20:34 +01:00
Compare commits
1 Commits
feat/v2-da
...
inline-log
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ffbb1ddb8 |
19
.github/CODEOWNERS
vendored
19
.github/CODEOWNERS
vendored
@@ -169,22 +169,3 @@ go.mod @therealpandey
|
||||
## Dashboard V2
|
||||
/frontend/src/pages/DashboardPageV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/DashboardsListPageV2/ @SigNoz/pulse-frontend
|
||||
|
||||
## Infrastructure Monitoring
|
||||
/frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/InfraMonitoringHosts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/InfraMonitoringK8s/ @SigNoz/pulse-frontend
|
||||
|
||||
## Alerts
|
||||
/frontend/src/pages/AlertList/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/AlertDetails/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/CreateAlert/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/EditRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AlertHistory/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertRule/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditAlertV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/FormAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
|
||||
|
||||
@@ -64,16 +64,16 @@ web:
|
||||
settings:
|
||||
posthog:
|
||||
# Whether to enable PostHog in web.
|
||||
enabled: false
|
||||
enabled: true
|
||||
appcues:
|
||||
# Whether to enable Appcues in web.
|
||||
enabled: false
|
||||
enabled: true
|
||||
sentry:
|
||||
# Whether to enable Sentry in web.
|
||||
enabled: false
|
||||
enabled: true
|
||||
pylon:
|
||||
# Whether to enable Pylon in web.
|
||||
enabled: false
|
||||
enabled: true
|
||||
|
||||
##################### Cache #####################
|
||||
cache:
|
||||
|
||||
@@ -6572,15 +6572,6 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
SpantypesOtelSpanRef:
|
||||
properties:
|
||||
refType:
|
||||
type: string
|
||||
spanId:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
type: object
|
||||
SpantypesPostableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -6844,10 +6835,6 @@ components:
|
||||
type: string
|
||||
parent_span_id:
|
||||
type: string
|
||||
references:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesOtelSpanRef'
|
||||
type: array
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
@@ -6873,8 +6860,6 @@ components:
|
||||
type: string
|
||||
trace_state:
|
||||
type: string
|
||||
required:
|
||||
- references
|
||||
type: object
|
||||
TagtypesPostableTag:
|
||||
properties:
|
||||
|
||||
@@ -7768,21 +7768,6 @@ export interface SpantypesGettableTraceAggregationsDTO {
|
||||
aggregations: SpantypesSpanAggregationResultDTO[];
|
||||
}
|
||||
|
||||
export interface SpantypesOtelSpanRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
refType?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
spanId?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -7877,10 +7862,6 @@ export interface SpantypesWaterfallSpanDTO {
|
||||
* @type string
|
||||
*/
|
||||
parent_span_id?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
references: SpantypesOtelSpanRefDTO[];
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
|
||||
@@ -120,8 +120,7 @@ export const interceptorRejected = async (
|
||||
!(
|
||||
response.config.url === '/sessions' && response.config.method === 'delete'
|
||||
) &&
|
||||
response.config.url !== '/authz/check' &&
|
||||
response.config.url !== '/api/v2/reset_password_tokens/verify'
|
||||
response.config.url !== '/authz/check'
|
||||
) {
|
||||
try {
|
||||
const accessToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN);
|
||||
|
||||
1
frontend/src/assets/Icons/solid-x-circle.svg
Normal file
1
frontend/src/assets/Icons/solid-x-circle.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#prefix__clip0_4062_7291)" stroke-width="1.167" stroke-linecap="round" stroke-linejoin="round"><path d="M7 12.833A5.833 5.833 0 107 1.167a5.833 5.833 0 000 11.666z" fill="#E5484D" stroke="#E5484D"/><path d="M8.75 5.25l-3.5 3.5M5.25 5.25l3.5 3.5" stroke="#121317"/></g><defs><clipPath id="prefix__clip0_4062_7291"><path fill="#fff" d="M0 0h14v14H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 467 B |
@@ -196,11 +196,7 @@ function Footer(): JSX.Element {
|
||||
</Button>
|
||||
);
|
||||
if (alertValidationMessage) {
|
||||
button = (
|
||||
<Tooltip title={alertValidationMessage}>
|
||||
<span>{button}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
||||
}
|
||||
return button;
|
||||
}, [
|
||||
@@ -228,11 +224,7 @@ function Footer(): JSX.Element {
|
||||
</Button>
|
||||
);
|
||||
if (alertValidationMessage) {
|
||||
button = (
|
||||
<Tooltip title={alertValidationMessage}>
|
||||
<span>{button}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
||||
}
|
||||
return button;
|
||||
}, [
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
padding: 12px 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
.settings-container-root {
|
||||
.ant-drawer-wrapper-body {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ant-drawer-header {
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 14px 14px 14px 11px;
|
||||
|
||||
.ant-drawer-header-title {
|
||||
gap: 16px;
|
||||
|
||||
.ant-drawer-title {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-inline-end: 0px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 16px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { memo, PropsWithChildren, ReactElement } from 'react';
|
||||
import { Drawer } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import './SettingsDrawer.styles.scss';
|
||||
|
||||
type SettingsDrawerProps = PropsWithChildren<{
|
||||
drawerTitle: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}>;
|
||||
|
||||
function SettingsDrawer({
|
||||
children,
|
||||
drawerTitle,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: SettingsDrawerProps): JSX.Element {
|
||||
return (
|
||||
<Drawer
|
||||
title={drawerTitle}
|
||||
placement="right"
|
||||
width="50%"
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
rootClassName="settings-container-root"
|
||||
>
|
||||
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
|
||||
<OverlayScrollbar>{children as ReactElement}</OverlayScrollbar>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SettingsDrawer);
|
||||
@@ -1,395 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
Check,
|
||||
ClipboardCopy,
|
||||
Ellipsis,
|
||||
FileJson,
|
||||
Fullscreen,
|
||||
Globe,
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
Plus,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Button, Card, Input, Modal, Popover, Tag, Tooltip } from 'antd';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
lockDashboardV2,
|
||||
patchDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
|
||||
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { Base64Icons } from '../../DashboardContainer/DashboardSettings/General/utils';
|
||||
import DashboardSettingsV2 from '../DashboardSettings';
|
||||
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
|
||||
import SettingsDrawer from './SettingsDrawer';
|
||||
|
||||
import '../../DashboardContainer/DashboardDescription/Description.styles.scss';
|
||||
|
||||
import type { V2Dashboard } from '../utils';
|
||||
|
||||
interface DashboardDescriptionV2Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
handle: FullScreenHandle;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function DashboardDescriptionV2(props: DashboardDescriptionV2Props): JSX.Element {
|
||||
const { dashboard, handle, onRefetch } = props;
|
||||
|
||||
const id = dashboard?.id ?? '';
|
||||
const isDashboardLocked = !!dashboard?.locked;
|
||||
|
||||
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const title = dashboard?.spec?.display?.name ?? '';
|
||||
const description = dashboard?.spec?.display?.description ?? '';
|
||||
const image = dashboard?.image || Base64Icons[0];
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
(dashboard?.tags ?? []).map((t) =>
|
||||
t.key === t.value ? t.key : `${t.key}:${t.value}`,
|
||||
),
|
||||
[dashboard?.tags],
|
||||
);
|
||||
|
||||
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const [isDashboardSettingsOpen, setIsDashbordSettingsOpen] =
|
||||
useState<boolean>(false);
|
||||
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard?.createdBy && dashboard.createdBy === user.email;
|
||||
const addPanelPermission = !isDashboardLocked;
|
||||
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
|
||||
const isPublicDashboard = false;
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
|
||||
const [isRenameLoading, setIsRenameLoading] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboard) {setUpdatedTitle(title);}
|
||||
}, [dashboard, title]);
|
||||
|
||||
const handleLockDashboardToggle = async (): Promise<void> => {
|
||||
if (!id) {return;}
|
||||
setIsDashbordSettingsOpen(false);
|
||||
try {
|
||||
if (isDashboardLocked) {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
} else {
|
||||
await lockDashboardV2({ id });
|
||||
toast.success('Dashboard locked');
|
||||
}
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
};
|
||||
|
||||
const onNameChangeHandler = async (): Promise<void> => {
|
||||
const trimmed = updatedTitle.trim();
|
||||
if (!id || !trimmed || trimmed === title) {
|
||||
setIsRenameDashboardOpen(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsRenameLoading(true);
|
||||
const patch: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/spec/display/name',
|
||||
value: trimmed,
|
||||
},
|
||||
];
|
||||
await patchDashboardV2({ id }, patch);
|
||||
toast.success('Dashboard renamed successfully');
|
||||
setIsRenameDashboardOpen(false);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
setIsRenameDashboardOpen(true);
|
||||
} finally {
|
||||
setIsRenameLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onEmptyWidgetHandler = (): void => {
|
||||
logEvent('Dashboard Detail V2: Add new panel clicked', {
|
||||
dashboardId: id,
|
||||
});
|
||||
toast.info('V2 panel editor coming next');
|
||||
};
|
||||
|
||||
const [state, setCopy] = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.error) {
|
||||
toast.error(t('something_went_wrong', { ns: 'common' }));
|
||||
}
|
||||
if (state.value) {
|
||||
toast.success(t('success', { ns: 'common' }));
|
||||
}
|
||||
}, [state.error, state.value, t]);
|
||||
|
||||
const dashboardDataJSON = (): string =>
|
||||
JSON.stringify(dashboard ?? {}, null, 2);
|
||||
|
||||
const exportJSON = (): void => {
|
||||
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${title || 'dashboard'}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const onConfigureClick = (): void => {
|
||||
setIsSettingsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const onSettingsDrawerClose = (): void => {
|
||||
setIsSettingsDrawerOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="dashboard-description-container">
|
||||
<DashboardHeader title={title} image={image} />
|
||||
<section className="dashboard-details">
|
||||
<div className="left-section">
|
||||
<img src={image} alt="dashboard-img" className="dashboard-img" />
|
||||
<Tooltip title={title.length > 30 ? title : ''}>
|
||||
<Typography.Text
|
||||
className="dashboard-title"
|
||||
data-testid="dashboard-title"
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
|
||||
{isPublicDashboard && (
|
||||
<Tooltip title="This dashboard is publicly accessible">
|
||||
<Globe size={14} className="public-dashboard-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<Tooltip title="This dashboard is locked">
|
||||
<LockKeyhole size={14} className="lock-dashboard-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="right-section">
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
<Popover
|
||||
open={isDashboardSettingsOpen}
|
||||
arrow={false}
|
||||
onOpenChange={(visible): void => setIsDashbordSettingsOpen(visible)}
|
||||
rootClassName="dashboard-settings"
|
||||
content={
|
||||
<div className="menu-content">
|
||||
<section className="section-1">
|
||||
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
|
||||
<Tooltip
|
||||
title={
|
||||
dashboard?.createdBy === 'integration' &&
|
||||
'Dashboards created by integrations cannot be unlocked'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LockKeyhole size={14} />}
|
||||
disabled={dashboard?.createdBy === 'integration'}
|
||||
onClick={handleLockDashboardToggle}
|
||||
data-testid="lock-unlock-dashboard"
|
||||
>
|
||||
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PenLine size={14} />}
|
||||
onClick={(): void => {
|
||||
setIsRenameDashboardOpen(true);
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Fullscreen size={14} />}
|
||||
onClick={handle.enter}
|
||||
>
|
||||
Full screen
|
||||
</Button>
|
||||
</section>
|
||||
<section className="section-2">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FileJson size={14} />}
|
||||
onClick={(): void => {
|
||||
exportJSON();
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ClipboardCopy size={14} />}
|
||||
onClick={(): void => {
|
||||
setCopy(dashboardDataJSON());
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Copy as JSON
|
||||
</Button>
|
||||
</section>
|
||||
<section className="delete-dashboard">
|
||||
<DeleteButton
|
||||
createdBy={dashboard?.createdBy || ''}
|
||||
name={title}
|
||||
id={id}
|
||||
isLocked={isDashboardLocked}
|
||||
routeToListPage
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button
|
||||
icon={<Ellipsis size={14} />}
|
||||
type="text"
|
||||
className="icons"
|
||||
data-testid="options"
|
||||
/>
|
||||
</Popover>
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
className="configure-button"
|
||||
icon={<ConfigureIcon />}
|
||||
data-testid="show-drawer"
|
||||
onClick={onConfigureClick}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={onSettingsDrawerClose}
|
||||
>
|
||||
<DashboardSettingsV2
|
||||
dashboard={dashboard}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
{!isDashboardLocked && addPanelPermission && (
|
||||
<Button
|
||||
className="add-panel-btn"
|
||||
onClick={onEmptyWidgetHandler}
|
||||
icon={<Plus size="md" />}
|
||||
type="primary"
|
||||
data-testid="add-panel-header"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{tags.length > 0 && (
|
||||
<div className="dashboard-tags">
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} className="tag">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(description) && (
|
||||
<section className="dashboard-description-section">{description}</section>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={isRenameDashboardOpen}
|
||||
title="Rename Dashboard"
|
||||
onOk={onNameChangeHandler}
|
||||
onCancel={(): void => {
|
||||
setIsRenameDashboardOpen(false);
|
||||
}}
|
||||
rootClassName="rename-dashboard"
|
||||
footer={
|
||||
<div className="dashboard-rename">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Check size={14} />}
|
||||
className="rename-btn"
|
||||
onClick={onNameChangeHandler}
|
||||
disabled={isRenameLoading}
|
||||
>
|
||||
Rename Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<X size={14} />}
|
||||
className="cancel-btn"
|
||||
onClick={(): void => setIsRenameDashboardOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="dashboard-content">
|
||||
<Typography.Text className="name-text">Enter a new name</Typography.Text>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className="dashboard-name-input"
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardDescriptionV2;
|
||||
@@ -1,227 +0,0 @@
|
||||
.overviewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overviewSettings {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.crossPanelSyncGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.crossPanelSyncInfoIcon {
|
||||
cursor: help;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipTitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDescription {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDocLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary-background);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
& + & {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.crossPanelSyncInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.nameIconInput {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dashboardImageInput {
|
||||
:global(.ant-select-selector) {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l3-background) !important;
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.ant-select-dropdown) {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
:global(.ant-select-item) {
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global(.ant-select-item-option-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.dashboardName {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.overviewSettingsFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 0px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unsavedDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.footerActionBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discardBtn {
|
||||
margin: '16px 0';
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
margin: 0px !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Radio to @signozhq/ui/radio-group
|
||||
import { Col, Input, Radio, Select, Space, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Check, ExternalLink, SolidInfoCircle, X } from '@signozhq/icons';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
TagtypesPostableTagDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import styles from './GeneralSettings.module.scss';
|
||||
import { Button } from './styles';
|
||||
import { Base64Icons } from './utils';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import type { V2Dashboard } from '../../utils';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
// Convert V2 tags ({key, value}[]) into "key:value" strings for the V1
|
||||
// AddTags component (which expects string[]), and back on save.
|
||||
//
|
||||
// V2 tags require both `key` and `value` to be non-empty server-side
|
||||
// (returns `tag_invalid_value` otherwise). To preserve the V1 single-word
|
||||
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
|
||||
// collapsed back to just `x` for display.
|
||||
function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
|
||||
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
|
||||
}
|
||||
|
||||
function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
|
||||
return tagStrings
|
||||
.map((s) => {
|
||||
const trimmed = s.trim();
|
||||
const idx = trimmed.indexOf(':');
|
||||
if (idx === -1) {
|
||||
return { key: trimmed, value: trimmed };
|
||||
}
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
return { key, value: value || key };
|
||||
})
|
||||
.filter((t) => t.key.length > 0);
|
||||
}
|
||||
|
||||
function GeneralDashboardSettingsV2({
|
||||
dashboard,
|
||||
onRefetch,
|
||||
}: Props): JSX.Element {
|
||||
const id = dashboard?.id ?? '';
|
||||
|
||||
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(id);
|
||||
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
|
||||
useSyncTooltipFilterMode(id);
|
||||
|
||||
const title = dashboard?.spec?.display?.name ?? '';
|
||||
const description = dashboard?.spec?.display?.description ?? '';
|
||||
const image = dashboard?.image || Base64Icons[0];
|
||||
const tagsAsStrings = useMemo(
|
||||
() => tagsToStrings(dashboard?.tags ?? []),
|
||||
[dashboard?.tags],
|
||||
);
|
||||
|
||||
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
|
||||
const [updatedTags, setUpdatedTags] = useState<string[]>(tagsAsStrings);
|
||||
const [updatedDescription, setUpdatedDescription] =
|
||||
useState<string>(description);
|
||||
const [updatedImage, setUpdatedImage] = useState<string>(image);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] =
|
||||
useState<number>(0);
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
// Sync state when dashboard refetches after a save
|
||||
useEffect(() => {
|
||||
setUpdatedTitle(title);
|
||||
setUpdatedDescription(description);
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard?.updatedAt]);
|
||||
|
||||
const buildPatch = (): DashboardtypesJSONPatchOperationDTO[] => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
const replace = (
|
||||
path: string,
|
||||
value: unknown,
|
||||
): DashboardtypesJSONPatchOperationDTO => ({
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path,
|
||||
value,
|
||||
});
|
||||
|
||||
if (updatedTitle !== title) {
|
||||
ops.push(replace('/spec/display/name', updatedTitle));
|
||||
}
|
||||
if (updatedDescription !== description) {
|
||||
ops.push(replace('/spec/display/description', updatedDescription));
|
||||
}
|
||||
if (updatedImage !== image) {
|
||||
ops.push(replace('/image', updatedImage));
|
||||
}
|
||||
if (!isEqual(updatedTags, tagsAsStrings)) {
|
||||
ops.push(replace('/tags', stringsToTags(updatedTags)));
|
||||
}
|
||||
return ops;
|
||||
};
|
||||
|
||||
const onSaveHandler = async (): Promise<void> => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const ops = buildPatch();
|
||||
if (ops.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id }, ops);
|
||||
toast.success('Dashboard updated');
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let n = 0;
|
||||
const initialValues = [title, description, tagsAsStrings, image];
|
||||
const updatedValues = [
|
||||
updatedTitle,
|
||||
updatedDescription,
|
||||
updatedTags,
|
||||
updatedImage,
|
||||
];
|
||||
initialValues.forEach((val, index) => {
|
||||
if (!isEqual(val, updatedValues[index])) {
|
||||
n += 1;
|
||||
}
|
||||
});
|
||||
setNumberOfUnsavedChanges(n);
|
||||
}, [
|
||||
description,
|
||||
image,
|
||||
tagsAsStrings,
|
||||
title,
|
||||
updatedDescription,
|
||||
updatedImage,
|
||||
updatedTags,
|
||||
updatedTitle,
|
||||
]);
|
||||
|
||||
const discardHandler = (): void => {
|
||||
setUpdatedTitle(title);
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
setUpdatedDescription(description);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overviewContent}>
|
||||
<Col className={styles.overviewSettings}>
|
||||
<Space
|
||||
direction="vertical"
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '21px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
defaultActiveFirstOption
|
||||
data-testid="dashboard-image"
|
||||
suffixIcon={null}
|
||||
rootClassName={styles.dashboardImageInput}
|
||||
value={updatedImage}
|
||||
onChange={(value: string): void => setUpdatedImage(value)}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<Option value={icon} key={icon}>
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Description</Typography>
|
||||
<Input.TextArea
|
||||
data-testid="dashboard-desc"
|
||||
rows={6}
|
||||
value={updatedDescription}
|
||||
className={styles.descriptionTextArea}
|
||||
onChange={(e): void => setUpdatedDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Tags</Typography>
|
||||
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
|
||||
<div className={styles.crossPanelSyncSectionHeader}>
|
||||
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
|
||||
Cross-Panel Sync
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
title={
|
||||
<div className={styles.crossPanelSyncTooltipContent}>
|
||||
<strong className={styles.crossPanelSyncTooltipTitle}>
|
||||
Cross-Panel Sync
|
||||
</strong>
|
||||
<span className={styles.crossPanelSyncTooltipDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</span>
|
||||
<a
|
||||
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.crossPanelSyncTooltipDocLink}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Sync Mode
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={cursorSyncMode}
|
||||
onChange={(e): void => {
|
||||
setCursorSyncMode(e.target.value as DashboardCursorSync);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Crosshair}>
|
||||
Crosshair
|
||||
</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{cursorSyncMode === DashboardCursorSync.Tooltip && (
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Synced Tooltip Series
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Show only series that intersect on group-by, or every series with the
|
||||
matching ones highlighted
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={syncTooltipFilterMode}
|
||||
onChange={(e): void => {
|
||||
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
mode: e.target.value,
|
||||
});
|
||||
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
|
||||
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
|
||||
Filtered
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
{numberOfUnsavedChanges > 0 && (
|
||||
<div className={styles.overviewSettingsFooter}>
|
||||
<div className={styles.unsaved}>
|
||||
<div className={styles.unsavedDot} />
|
||||
<Typography.Text className={styles.unsavedChanges}>
|
||||
{numberOfUnsavedChanges} unsaved change
|
||||
{numberOfUnsavedChanges > 1 && 's'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.footerActionBtns}>
|
||||
<Button
|
||||
disabled={isSaving}
|
||||
icon={<X size={14} />}
|
||||
onClick={discardHandler}
|
||||
type="text"
|
||||
className={styles.discardBtn}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
style={{ margin: '16px 0' }}
|
||||
disabled={isSaving}
|
||||
loading={isSaving}
|
||||
icon={<Check size={14} />}
|
||||
data-testid="save-dashboard-config"
|
||||
onClick={onSaveHandler}
|
||||
type="primary"
|
||||
className={styles.saveBtn}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GeneralDashboardSettingsV2;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Button as ButtonComponent, Drawer } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
margin-top: 0.5rem;
|
||||
`;
|
||||
|
||||
export const Button = styled(ButtonComponent)`
|
||||
&&& {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
export const DrawerContainer = styled(Drawer)`
|
||||
.ant-drawer-header {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
`;
|
||||
File diff suppressed because one or more lines are too long
@@ -1,67 +0,0 @@
|
||||
import { Button, Empty, Tabs } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Braces, Globe, Table } from '@signozhq/icons';
|
||||
|
||||
import '../../DashboardContainer/DashboardSettings/DashboardSettingsContent.styles.scss';
|
||||
|
||||
import GeneralDashboardSettingsV2 from './General';
|
||||
import type { V2Dashboard } from '../utils';
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function Placeholder({ message }: { message: string }): JSX.Element {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<Typography.Text>{message}</Typography.Text>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardSettingsV2({ dashboard, onRefetch }: Props): JSX.Element {
|
||||
const items = [
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Table size={14} />}>
|
||||
General
|
||||
</Button>
|
||||
),
|
||||
key: 'general',
|
||||
children: (
|
||||
<GeneralDashboardSettingsV2
|
||||
dashboard={dashboard}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Braces size={14} />}>
|
||||
Variables
|
||||
</Button>
|
||||
),
|
||||
key: 'variables',
|
||||
children: <Placeholder message="V2 dashboard variables coming next." />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Globe size={14} />}>
|
||||
Publish
|
||||
</Button>
|
||||
),
|
||||
key: 'public-dashboard',
|
||||
children: (
|
||||
<Placeholder message="V2 public dashboard publishing coming next." />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return <Tabs items={items} />;
|
||||
}
|
||||
|
||||
export default DashboardSettingsV2;
|
||||
@@ -1,4 +0,0 @@
|
||||
.emptyState {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
|
||||
import type { DeletePanelArgs } from '../hooks/useDeletePanel';
|
||||
import styles from './PanelActionsMenu.module.scss';
|
||||
|
||||
interface Props {
|
||||
panelId: string;
|
||||
currentLayoutIndex: number;
|
||||
sections: DashboardSectionV2[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function PanelActionsMenu({
|
||||
panelId,
|
||||
currentLayoutIndex,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: Props): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const result: MenuItem[] = [];
|
||||
|
||||
if (onMovePanel) {
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
result.push({
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
disabled: true,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
onMovePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (onDeletePanel) {
|
||||
if (result.length > 0) {
|
||||
result.push({ type: 'divider' });
|
||||
}
|
||||
result.push({
|
||||
key: 'delete-panel',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: 'Delete panel',
|
||||
onClick: (): void =>
|
||||
onDeletePanel({ panelId, layoutIndex: currentLayoutIndex }),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [sections, currentLayoutIndex, panelId, onMovePanel, onDeletePanel]);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
aria-label="Panel actions"
|
||||
data-testid={`panel-actions-${panelId}`}
|
||||
// Stop pointer/mouse down from reaching the RGL drag handle this
|
||||
// button lives inside, so opening the menu never starts a panel drag.
|
||||
onPointerDown={(e): void => e.stopPropagation()}
|
||||
onMouseDown={(e): void => e.stopPropagation()}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelActionsMenu;
|
||||
@@ -1,22 +0,0 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.typeButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
border: 1px solid var(--bg-slate-400, #1d212d);
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Modal } from 'antd';
|
||||
import {
|
||||
BarChart,
|
||||
ChartLine,
|
||||
ChartPie,
|
||||
Hash,
|
||||
List,
|
||||
Table,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import styles from './PanelTypeSelectionModalV2.module.scss';
|
||||
|
||||
interface PanelType {
|
||||
pluginKind: string;
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
}
|
||||
|
||||
const PANEL_TYPES: PanelType[] = [
|
||||
{
|
||||
pluginKind: 'signoz/TimeSeriesPanel',
|
||||
label: 'Time Series',
|
||||
icon: <ChartLine size={16} />,
|
||||
},
|
||||
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
|
||||
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
|
||||
{
|
||||
pluginKind: 'signoz/BarChartPanel',
|
||||
label: 'Bar Chart',
|
||||
icon: <BarChart size={16} />,
|
||||
},
|
||||
{
|
||||
pluginKind: 'signoz/PieChartPanel',
|
||||
label: 'Pie Chart',
|
||||
icon: <ChartPie size={16} />,
|
||||
},
|
||||
{
|
||||
pluginKind: 'signoz/HistogramPanel',
|
||||
label: 'Histogram',
|
||||
icon: <BarChart size={16} />,
|
||||
},
|
||||
{ pluginKind: 'signoz/ListPanel', label: 'List', icon: <List size={16} /> },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (pluginKind: string) => void;
|
||||
}
|
||||
|
||||
function PanelTypeSelectionModalV2({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Select a panel type"
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className={styles.grid}>
|
||||
{PANEL_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.pluginKind}
|
||||
type="button"
|
||||
className={styles.typeButton}
|
||||
data-testid={`panel-type-${type.pluginKind}`}
|
||||
onClick={(): void => onSelect(type.pluginKind)}
|
||||
>
|
||||
{type.icon}
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelTypeSelectionModalV2;
|
||||
@@ -1,52 +0,0 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
border: 1px solid var(--bg-slate-400, #1d212d);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--bg-slate-400, #1d212d);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bodyKind {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { EllipsisVertical } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
|
||||
import type { DeletePanelArgs } from '../hooks/useDeletePanel';
|
||||
import PanelActionsMenu from '../PanelActionsMenu/PanelActionsMenu';
|
||||
import styles from './PanelV2.module.scss';
|
||||
|
||||
interface Props {
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
panelId: string;
|
||||
/**
|
||||
* Placeholder: true once this panel's section enters the viewport. The panel
|
||||
* query-loading implementation (later PR) will consume this to lazily fetch
|
||||
* data. Currently unused on purpose.
|
||||
*/
|
||||
isVisible?: boolean;
|
||||
/** Section actions — present only in editable sectioned mode. */
|
||||
currentLayoutIndex?: number;
|
||||
sections?: DashboardSectionV2[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function PanelV2({
|
||||
panel,
|
||||
panelId,
|
||||
isVisible,
|
||||
currentLayoutIndex,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: Props): JSX.Element {
|
||||
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
|
||||
const description = panel?.spec?.display?.description;
|
||||
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
|
||||
const queryCount = panel?.spec?.queries?.length ?? 0;
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!description) {
|
||||
return name;
|
||||
}
|
||||
return (
|
||||
<Tooltip title={description}>
|
||||
<span>{name}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}, [name, description]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
data-panel-visible={isVisible ? 'true' : 'false'}
|
||||
>
|
||||
<div className={`${styles.header} panel-drag-handle`}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Typography.Text className={styles.headerTitle}>
|
||||
{headerTitle}
|
||||
</Typography.Text>
|
||||
<Badge className={styles.badge}>{kind}</Badge>
|
||||
</div>
|
||||
{currentLayoutIndex !== undefined && (onMovePanel || onDeletePanel) ? (
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
currentLayoutIndex={currentLayoutIndex}
|
||||
sections={sections ?? []}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
) : (
|
||||
<EllipsisVertical size={14} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div>
|
||||
<div className={styles.bodyKind}>{kind} panel</div>
|
||||
<div>
|
||||
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering
|
||||
coming next
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelV2;
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import {
|
||||
addPanelToSectionOps,
|
||||
createDefaultPanel,
|
||||
panelRef,
|
||||
} from '../../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSectionV2[];
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
export interface AddPanelArgs {
|
||||
layoutIndex: number;
|
||||
pluginKind: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new panel and places its item ref at the bottom of the target
|
||||
* section, as one atomic patch. Structure-only: the panel is a valid minimal
|
||||
* placeholder (its query is filled in once the panel editor lands).
|
||||
*/
|
||||
export function useAddPanelToSection({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): (args: AddPanelArgs) => Promise<void> {
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
return useCallback(
|
||||
async ({ layoutIndex, pluginKind }: AddPanelArgs): Promise<void> => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
const target = sections.find((s) => s.layoutIndex === layoutIndex);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelId = uuid();
|
||||
const nextY = target.items.reduce(
|
||||
(max, i) => Math.max(max, i.y + i.height),
|
||||
0,
|
||||
);
|
||||
|
||||
try {
|
||||
await patchDashboardV2(
|
||||
{ id: dashboardId },
|
||||
addPanelToSectionOps({
|
||||
panelId,
|
||||
panel: createDefaultPanel(pluginKind),
|
||||
layoutIndex,
|
||||
item: {
|
||||
x: 0,
|
||||
y: nextY,
|
||||
width: 6,
|
||||
height: 6,
|
||||
content: { $ref: panelRef(panelId) },
|
||||
},
|
||||
}),
|
||||
);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { removePanelOp, replaceSectionItemsOp } from '../../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSectionV2[];
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
export interface DeletePanelArgs {
|
||||
panelId: string;
|
||||
layoutIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a panel: drops its item ref from the section's items and deletes the
|
||||
* panel from `spec.panels`, as one atomic patch.
|
||||
*/
|
||||
export function useDeletePanel({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): (args: DeletePanelArgs) => Promise<void> {
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
return useCallback(
|
||||
async ({ panelId, layoutIndex }: DeletePanelArgs): Promise<void> => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
const section = sections.find((s) => s.layoutIndex === layoutIndex);
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextItems = section.items.filter((i) => i.id !== panelId);
|
||||
try {
|
||||
await patchDashboardV2({ id: dashboardId }, [
|
||||
replaceSectionItemsOp(layoutIndex, nextItems),
|
||||
removePanelOp(panelId),
|
||||
]);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { movePanelBetweenSectionsOps } from '../../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
|
||||
export interface MovePanelArgs {
|
||||
panelId: string;
|
||||
fromLayoutIndex: number;
|
||||
toLayoutIndex: number;
|
||||
}
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSectionV2[];
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relocates a panel's item ref from one section to another. The panel itself
|
||||
* stays in `spec.panels`; only the grid item moves, dropped into a free row at
|
||||
* the bottom of the target section. Persisted as one atomic patch.
|
||||
*/
|
||||
export function useMovePanelToSection({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): (args: MovePanelArgs) => Promise<void> {
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
return useCallback(
|
||||
async ({
|
||||
panelId,
|
||||
fromLayoutIndex,
|
||||
toLayoutIndex,
|
||||
}: MovePanelArgs): Promise<void> => {
|
||||
if (!dashboardId || fromLayoutIndex === toLayoutIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = sections.find((s) => s.layoutIndex === fromLayoutIndex);
|
||||
const target = sections.find((s) => s.layoutIndex === toLayoutIndex);
|
||||
if (!source || !target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const moved = source.items.find((i) => i.id === panelId);
|
||||
if (!moved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceItems = source.items.filter((i) => i.id !== panelId);
|
||||
// Place at a fresh row at the bottom of the target section.
|
||||
const nextY = target.items.reduce(
|
||||
(max, i) => Math.max(max, i.y + i.height),
|
||||
0,
|
||||
);
|
||||
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
|
||||
|
||||
try {
|
||||
await patchDashboardV2(
|
||||
{ id: dashboardId },
|
||||
movePanelBetweenSectionsOps({
|
||||
sourceIndex: fromLayoutIndex,
|
||||
sourceItems,
|
||||
targetIndex: toLayoutIndex,
|
||||
targetItems,
|
||||
}),
|
||||
);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
.addButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--bg-slate-400, #1d212d);
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-robin-500);
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
import { useAddSection } from '../hooks/useAddSection';
|
||||
import { useFirstSectionMigration } from '../hooks/useFirstSectionMigration';
|
||||
import FirstSectionMigrationModal from '../FirstSectionMigrationModal';
|
||||
import styles from './AddSectionControl.module.scss';
|
||||
|
||||
const DEFAULT_SECTION_TITLE = 'New section';
|
||||
|
||||
interface Props {
|
||||
sections: DashboardSectionV2[];
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
dashboardId: string | undefined;
|
||||
isSectioned: boolean;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function AddSectionControl({
|
||||
sections,
|
||||
layouts,
|
||||
dashboardId,
|
||||
isSectioned,
|
||||
onRefetch,
|
||||
}: Props): JSX.Element {
|
||||
const [isMigrationOpen, setIsMigrationOpen] = useState(false);
|
||||
const { addSection } = useAddSection({ layouts, dashboardId, onRefetch });
|
||||
const { migrate, isSaving } = useFirstSectionMigration({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
});
|
||||
|
||||
// Free-flowing dashboard with existing panels → must migrate before sections
|
||||
// can coexist (every panel must belong to a section once any exists).
|
||||
const needsMigration =
|
||||
!isSectioned && sections.some((s) => s.items.length > 0);
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (needsMigration) {
|
||||
setIsMigrationOpen(true);
|
||||
return;
|
||||
}
|
||||
void addSection(DEFAULT_SECTION_TITLE);
|
||||
};
|
||||
|
||||
const handleConfirmMigration = async (): Promise<void> => {
|
||||
await migrate(DEFAULT_SECTION_TITLE);
|
||||
setIsMigrationOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addButton}
|
||||
onClick={handleClick}
|
||||
data-testid="add-section"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add section
|
||||
</button>
|
||||
<FirstSectionMigrationModal
|
||||
open={isMigrationOpen}
|
||||
isSaving={isSaving}
|
||||
onClose={(): void => setIsMigrationOpen(false)}
|
||||
onConfirm={handleConfirmMigration}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddSectionControl;
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when the user adds the first section to a free-flowing dashboard that
|
||||
* already has panels. Confirms grouping the existing panels into a section
|
||||
* before proceeding.
|
||||
*/
|
||||
function FirstSectionMigrationModal({
|
||||
open,
|
||||
isSaving,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Group panels into sections?"
|
||||
onCancel={onClose}
|
||||
onOk={onConfirm}
|
||||
okText="Continue"
|
||||
okButtonProps={{ disabled: isSaving, 'data-testid': 'confirm-migration' }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Typography.Text>
|
||||
This dashboard's panels are currently free-flowing. Adding a section
|
||||
will move the existing panels into their own section, and a new empty
|
||||
section will be added below. You can rename sections afterwards.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default FirstSectionMigrationModal;
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initialValue: string;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (title: string) => void;
|
||||
}
|
||||
|
||||
function RenameSectionModal({
|
||||
open,
|
||||
initialValue,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: Props): JSX.Element {
|
||||
const [value, setValue] = useState<string>(initialValue);
|
||||
|
||||
// Reseed the field each time the modal opens.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValue(initialValue);
|
||||
}
|
||||
}, [open, initialValue]);
|
||||
|
||||
const submit = (): void => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
onSubmit(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Rename section"
|
||||
onCancel={onClose}
|
||||
onOk={submit}
|
||||
okText="Rename"
|
||||
okButtonProps={{ disabled: isSaving || !value.trim() }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Input
|
||||
testId="rename-section-input"
|
||||
autoFocus
|
||||
value={value}
|
||||
maxLength={120}
|
||||
placeholder="Section name"
|
||||
onChange={(e): void => setValue(e.target.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default RenameSectionModal;
|
||||
@@ -1,9 +0,0 @@
|
||||
.section {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
import { useRenameSection } from '../hooks/useRenameSection';
|
||||
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
|
||||
import { useDeleteSection } from '../hooks/useDeleteSection';
|
||||
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
|
||||
import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection';
|
||||
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
|
||||
import PanelTypeSelectionModalV2 from '../../Panel/PanelTypeSelectionModalV2/PanelTypeSelectionModalV2';
|
||||
import RenameSectionModal from '../RenameSectionModal';
|
||||
import SectionGrid from '../SectionGrid/SectionGrid';
|
||||
import SectionHeader, { type SectionDragHandle } from '../SectionHeader/SectionHeader';
|
||||
import styles from './Section.module.scss';
|
||||
|
||||
interface Props {
|
||||
section: DashboardSectionV2;
|
||||
dashboardId: string | undefined;
|
||||
isEditable: boolean;
|
||||
onRefetch: () => void;
|
||||
/** All sections + move handler, for the per-panel "Move to section" action. */
|
||||
sections?: DashboardSectionV2[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
/** Adds a panel to this section; present only in editable sectioned mode. */
|
||||
onAddPanel?: (args: AddPanelArgs) => void;
|
||||
/** Deletes a panel from this section. */
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow sections. */
|
||||
dragHandle?: SectionDragHandle;
|
||||
}
|
||||
|
||||
function Section({
|
||||
section,
|
||||
dashboardId,
|
||||
isEditable,
|
||||
onRefetch,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onAddPanel,
|
||||
onDeletePanel,
|
||||
dragHandle,
|
||||
}: Props): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
|
||||
// true once the section scrolls into (or near) the viewport.
|
||||
const isVisible = useIntersectionObserver(containerRef, {
|
||||
rootMargin: '200px',
|
||||
});
|
||||
|
||||
const { open, toggle } = useToggleSectionCollapse({
|
||||
layoutIndex: section.layoutIndex,
|
||||
initialOpen: section.open,
|
||||
dashboardId,
|
||||
});
|
||||
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const { rename, isSaving } = useRenameSection({
|
||||
layoutIndex: section.layoutIndex,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
});
|
||||
|
||||
const handleRenameSubmit = async (title: string): Promise<void> => {
|
||||
const ok = await rename(title);
|
||||
if (ok) {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [isAddingPanel, setIsAddingPanel] = useState(false);
|
||||
const handleSelectPanelType = (pluginKind: string): void => {
|
||||
onAddPanel?.({ layoutIndex: section.layoutIndex, pluginKind });
|
||||
setIsAddingPanel(false);
|
||||
};
|
||||
|
||||
const { deleteSection } = useDeleteSection({
|
||||
section,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
});
|
||||
const confirmDeleteSection = (): void => {
|
||||
Modal.confirm({
|
||||
title: `Delete section "${section.title ?? ''}"?`,
|
||||
content: 'Panels in this section will be removed.',
|
||||
okText: 'Delete',
|
||||
okButtonProps: { danger: true },
|
||||
centered: true,
|
||||
onOk: () => deleteSection(),
|
||||
});
|
||||
};
|
||||
|
||||
const grid = (
|
||||
<SectionGrid
|
||||
items={section.items}
|
||||
layoutIndex={section.layoutIndex}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
isVisible={isVisible}
|
||||
sections={sections}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!section.title) {
|
||||
// Untitled section — just the grid (no header chrome), but still observed
|
||||
// for the viewport signal.
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
>
|
||||
{grid}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.section}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
>
|
||||
<SectionHeader
|
||||
sectionId={section.id}
|
||||
title={section.title}
|
||||
open={open}
|
||||
onToggle={toggle}
|
||||
repeatVariable={section.repeatVariable}
|
||||
dragHandle={dragHandle}
|
||||
onRename={isEditable ? (): void => setIsRenaming(true) : undefined}
|
||||
onAddPanel={
|
||||
isEditable && onAddPanel ? (): void => setIsAddingPanel(true) : undefined
|
||||
}
|
||||
onDeleteSection={isEditable ? confirmDeleteSection : undefined}
|
||||
/>
|
||||
{open ? grid : null}
|
||||
<RenameSectionModal
|
||||
open={isRenaming}
|
||||
initialValue={section.title}
|
||||
isSaving={isSaving}
|
||||
onClose={(): void => setIsRenaming(false)}
|
||||
onSubmit={handleRenameSubmit}
|
||||
/>
|
||||
<PanelTypeSelectionModalV2
|
||||
open={isAddingPanel}
|
||||
onClose={(): void => setIsAddingPanel(false)}
|
||||
onSelect={handleSelectPanelType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Section;
|
||||
@@ -1,16 +0,0 @@
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import styles from './SectionActionsMenu.module.scss';
|
||||
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
onAddPanel?: () => void;
|
||||
onRename?: () => void;
|
||||
onDeleteSection?: () => void;
|
||||
}
|
||||
|
||||
function SectionActionsMenu({
|
||||
sectionId,
|
||||
onAddPanel,
|
||||
onRename,
|
||||
onDeleteSection,
|
||||
}: Props): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const result: MenuItem[] = [];
|
||||
if (onAddPanel) {
|
||||
result.push({
|
||||
key: 'add-panel',
|
||||
icon: <Plus size={14} />,
|
||||
label: 'Add panel',
|
||||
onClick: onAddPanel,
|
||||
});
|
||||
}
|
||||
if (onRename) {
|
||||
result.push({
|
||||
key: 'rename',
|
||||
icon: <PenLine size={14} />,
|
||||
label: 'Rename section',
|
||||
onClick: onRename,
|
||||
});
|
||||
}
|
||||
if (onDeleteSection) {
|
||||
result.push(
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'delete-section',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: 'Delete section',
|
||||
onClick: onDeleteSection,
|
||||
},
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [onAddPanel, onRename, onDeleteSection]);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
aria-label="Section actions"
|
||||
data-testid={`dashboard-section-actions-${sectionId}`}
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionActionsMenu;
|
||||
@@ -1,7 +0,0 @@
|
||||
.preview {
|
||||
border: 1px solid var(--bg-robin-500);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 40%);
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
import SectionHeader from '../SectionHeader/SectionHeader';
|
||||
import styles from './SectionDragPreview.module.scss';
|
||||
|
||||
interface Props {
|
||||
section: DashboardSectionV2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight preview rendered inside the DragOverlay while a section is being
|
||||
* dragged. Deliberately header-only (no react-grid-layout) so the overlay is
|
||||
* cheap and never triggers RGL width re-measurement.
|
||||
*/
|
||||
function SectionDragPreview({ section }: Props): JSX.Element {
|
||||
const panelCount = section.items.length;
|
||||
const title = `${section.title ?? ''} · ${panelCount} ${
|
||||
panelCount === 1 ? 'panel' : 'panels'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<SectionHeader
|
||||
sectionId={`${section.id}-preview`}
|
||||
title={title}
|
||||
open={false}
|
||||
onToggle={(): void => undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionDragPreview;
|
||||
@@ -1,12 +0,0 @@
|
||||
.grid {
|
||||
// Override react-grid-layout's default red drag/resize placeholder with the
|
||||
// SigNoz brand blue.
|
||||
:global(.react-grid-item.react-grid-placeholder) {
|
||||
background: var(--bg-robin-500);
|
||||
opacity: 0.2;
|
||||
border-radius: 4px;
|
||||
transition-duration: 100ms;
|
||||
z-index: 2;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import GridLayout, {
|
||||
type ItemCallback,
|
||||
WidthProvider,
|
||||
type Layout,
|
||||
} from 'react-grid-layout';
|
||||
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
import { usePersistLayout } from '../hooks/usePersistLayout';
|
||||
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
|
||||
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
|
||||
import PanelV2 from '../../Panel/PanelV2/PanelV2';
|
||||
import styles from './SectionGrid.module.scss';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(GridLayout);
|
||||
|
||||
interface Props {
|
||||
items: DashboardSectionV2['items'];
|
||||
layoutIndex: number;
|
||||
dashboardId: string | undefined;
|
||||
isEditable: boolean;
|
||||
onRefetch: () => void;
|
||||
/** Forwarded to panels — true when the parent section is in the viewport. */
|
||||
isVisible?: boolean;
|
||||
/** All sections + move handler — present only in editable sectioned mode (panel "Move to section"). */
|
||||
sections?: DashboardSectionV2[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function SectionGrid({
|
||||
items,
|
||||
layoutIndex,
|
||||
dashboardId,
|
||||
isEditable,
|
||||
onRefetch,
|
||||
isVisible,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: Props): JSX.Element {
|
||||
const rglLayout = useMemo<Layout[]>(
|
||||
() =>
|
||||
items.map((item) => ({
|
||||
i: item.id,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
})),
|
||||
[items],
|
||||
);
|
||||
|
||||
const { handleLayoutChange } = usePersistLayout({
|
||||
layoutIndex,
|
||||
items,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
});
|
||||
|
||||
// On drop, if the pointer is released over a different section, move the
|
||||
// panel there instead of repositioning it within this section. RGL clamps
|
||||
// the dragged item visually to this grid, but the pointer is free, so we
|
||||
// hit-test the release point against section containers.
|
||||
const handleDragStop = useCallback<ItemCallback>(
|
||||
// eslint-disable-next-line max-params -- signature fixed by react-grid-layout's ItemCallback
|
||||
(layout, oldItem, _newItem, _placeholder, event) => {
|
||||
// Deterministically hit-test the release point against section
|
||||
// containers (rect-based, so the dragged item on top doesn't interfere).
|
||||
const targetEl = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-section-layout-index]'),
|
||||
).find((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return (
|
||||
event.clientX >= r.left &&
|
||||
event.clientX <= r.right &&
|
||||
event.clientY >= r.top &&
|
||||
event.clientY <= r.bottom
|
||||
);
|
||||
});
|
||||
const attr = targetEl?.getAttribute('data-section-layout-index');
|
||||
const targetIndex = attr != null ? Number(attr) : null;
|
||||
|
||||
if (onMovePanel && targetIndex != null && targetIndex !== layoutIndex) {
|
||||
onMovePanel({
|
||||
panelId: oldItem.i,
|
||||
fromLayoutIndex: layoutIndex,
|
||||
toLayoutIndex: targetIndex,
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleLayoutChange(layout);
|
||||
},
|
||||
[onMovePanel, layoutIndex, handleLayoutChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveGridLayout
|
||||
className={styles.grid}
|
||||
cols={12}
|
||||
rowHeight={45}
|
||||
autoSize
|
||||
useCSSTransforms
|
||||
layout={rglLayout}
|
||||
draggableHandle=".panel-drag-handle"
|
||||
isDraggable={isEditable}
|
||||
isResizable={isEditable}
|
||||
onDragStop={handleDragStop}
|
||||
onResizeStop={handleLayoutChange}
|
||||
margin={[8, 8]}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>
|
||||
<PanelV2
|
||||
panel={item.panel}
|
||||
panelId={item.id}
|
||||
isVisible={isVisible}
|
||||
currentLayoutIndex={layoutIndex}
|
||||
sections={isEditable ? sections : undefined}
|
||||
onMovePanel={isEditable ? onMovePanel : undefined}
|
||||
onDeletePanel={isEditable ? onDeletePanel : undefined}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionGrid;
|
||||
@@ -1,52 +0,0 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
|
||||
&.headerOpen {
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.repeatBadge {
|
||||
margin-left: 8px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { DraggableAttributes } from '@dnd-kit/core';
|
||||
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
|
||||
import { ChevronDown, ChevronRight, GripVertical } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import SectionActionsMenu from '../SectionActionsMenu/SectionActionsMenu';
|
||||
import styles from './SectionHeader.module.scss';
|
||||
|
||||
export interface SectionDragHandle {
|
||||
attributes: DraggableAttributes;
|
||||
listeners: SyntheticListenerMap | undefined;
|
||||
setActivatorNodeRef: (element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
repeatVariable?: string;
|
||||
dragHandle?: SectionDragHandle;
|
||||
onRename?: () => void;
|
||||
onAddPanel?: () => void;
|
||||
onDeleteSection?: () => void;
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
sectionId,
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
repeatVariable,
|
||||
dragHandle,
|
||||
onRename,
|
||||
onAddPanel,
|
||||
onDeleteSection,
|
||||
}: Props): JSX.Element {
|
||||
const hasActions = !!(onAddPanel || onRename || onDeleteSection);
|
||||
return (
|
||||
<div className={`${styles.header} ${open ? styles.headerOpen : ''}`}>
|
||||
{dragHandle ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dragHandle}
|
||||
ref={dragHandle.setActivatorNodeRef}
|
||||
aria-label="Drag to reorder section"
|
||||
data-testid={`dashboard-section-drag-${sectionId}`}
|
||||
{...dragHandle.attributes}
|
||||
{...dragHandle.listeners}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
onClick={onToggle}
|
||||
data-testid={`dashboard-section-toggle-${sectionId}`}
|
||||
>
|
||||
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
{repeatVariable ? (
|
||||
<Typography.Text className={styles.repeatBadge}>
|
||||
(repeats per ${repeatVariable})
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</button>
|
||||
{hasActions ? (
|
||||
<SectionActionsMenu
|
||||
sectionId={sectionId}
|
||||
onAddPanel={onAddPanel}
|
||||
onRename={onRename}
|
||||
onDeleteSection={onDeleteSection}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionHeader;
|
||||
@@ -1,124 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { closestCenter, DndContext, DragOverlay } from '@dnd-kit/core';
|
||||
import {
|
||||
restrictToParentElement,
|
||||
restrictToVerticalAxis,
|
||||
} from '@dnd-kit/modifiers';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardSectionV2 } from '../../utils';
|
||||
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
|
||||
import { useMovePanelToSection } from '../Panel/hooks/useMovePanelToSection';
|
||||
import { useAddPanelToSection } from '../Panel/hooks/useAddPanelToSection';
|
||||
import { useDeletePanel } from '../Panel/hooks/useDeletePanel';
|
||||
import Section from './Section/Section';
|
||||
import SectionDragPreview from './SectionDragPreview/SectionDragPreview';
|
||||
import SortableSection from './SortableSection';
|
||||
|
||||
interface Props {
|
||||
sections: DashboardSectionV2[];
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
dashboardId: string | undefined;
|
||||
isEditable: boolean;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function SectionList({
|
||||
sections,
|
||||
layouts,
|
||||
dashboardId,
|
||||
isEditable,
|
||||
onRefetch,
|
||||
}: Props): JSX.Element {
|
||||
const {
|
||||
sensors,
|
||||
orderedSections,
|
||||
activeSection,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragCancel,
|
||||
} = useSectionDragReorder({ sections, layouts, dashboardId, onRefetch });
|
||||
|
||||
const onMovePanel = useMovePanelToSection({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
});
|
||||
const onAddPanel = useAddPanelToSection({ sections, dashboardId, onRefetch });
|
||||
const onDeletePanel = useDeletePanel({ sections, dashboardId, onRefetch });
|
||||
|
||||
// Only titled sections participate in reordering; untitled (free-flow)
|
||||
// blocks render in place without a drag handle.
|
||||
const sortableIds = useMemo(
|
||||
() => orderedSections.filter((s) => s.title).map((s) => s.id),
|
||||
[orderedSections],
|
||||
);
|
||||
|
||||
if (!isEditable) {
|
||||
return (
|
||||
<>
|
||||
{sections.map((section) => (
|
||||
<Section
|
||||
key={section.id}
|
||||
section={section}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={onDragCancel}
|
||||
>
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
{orderedSections.map((section) =>
|
||||
section.title ? (
|
||||
<SortableSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
sections={sections}
|
||||
onMovePanel={onMovePanel}
|
||||
onAddPanel={onAddPanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
) : (
|
||||
<Section
|
||||
key={section.id}
|
||||
section={section}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
sections={sections}
|
||||
onMovePanel={onMovePanel}
|
||||
onAddPanel={onAddPanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</SortableContext>
|
||||
{/* dropAnimation disabled: optimistic reorder already places the section,
|
||||
so animating the overlay back would cause a visible snap/shake. */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeSection ? <SectionDragPreview section={activeSection} /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionList;
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import type { DashboardSectionV2 } from '../../utils';
|
||||
import type { MovePanelArgs } from '../Panel/hooks/useMovePanelToSection';
|
||||
import type { AddPanelArgs } from '../Panel/hooks/useAddPanelToSection';
|
||||
import type { DeletePanelArgs } from '../Panel/hooks/useDeletePanel';
|
||||
import Section from './Section/Section';
|
||||
|
||||
interface Props {
|
||||
section: DashboardSectionV2;
|
||||
dashboardId: string | undefined;
|
||||
isEditable: boolean;
|
||||
onRefetch: () => void;
|
||||
sections: DashboardSectionV2[];
|
||||
onMovePanel: (args: MovePanelArgs) => void;
|
||||
onAddPanel: (args: AddPanelArgs) => void;
|
||||
onDeletePanel: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function SortableSection({
|
||||
section,
|
||||
dashboardId,
|
||||
isEditable,
|
||||
onRefetch,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onAddPanel,
|
||||
onDeletePanel,
|
||||
}: Props): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: section.id });
|
||||
|
||||
// dnd-kit drives the drag transform per-frame, so this must be an inline
|
||||
// style — there is no static-stylesheet equivalent for a live transform.
|
||||
// While dragging, the original is hidden (the DragOverlay renders the moving
|
||||
// preview); keeping it in place preserves the gap and lets siblings animate.
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0 : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<Section
|
||||
section={section}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
sections={sections}
|
||||
onMovePanel={onMovePanel}
|
||||
onAddPanel={onAddPanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SortableSection;
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { addSectionOp, newGridLayout, reorderLayoutsOp } from '../../../patchOps';
|
||||
|
||||
interface Params {
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
addSection: (title: string) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends an empty titled section. When the dashboard has no layouts yet, the
|
||||
* layouts array is created via a `replace` (an `add` to a missing/empty array
|
||||
* pointer is unreliable); otherwise a new Grid is appended.
|
||||
*/
|
||||
export function useAddSection({
|
||||
layouts,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const addSection = useCallback(
|
||||
async (title: string): Promise<void> => {
|
||||
const trimmed = title.trim();
|
||||
if (!dashboardId || !trimmed) {
|
||||
return;
|
||||
}
|
||||
const op =
|
||||
!layouts || layouts.length === 0
|
||||
? reorderLayoutsOp([newGridLayout(trimmed)])
|
||||
: addSectionOp(trimmed);
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, [op]);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[layouts, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
return { addSection, isSaving };
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { removePanelOp, removeSectionOp } from '../../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
section: DashboardSectionV2;
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
deleteSection: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a section: removes its Grid layout and deletes every panel it
|
||||
* contained from `spec.panels` (orphan cleanup), as one atomic patch.
|
||||
*/
|
||||
export function useDeleteSection({
|
||||
section,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const deleteSection = useCallback(async (): Promise<void> => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = section.items.map((i) =>
|
||||
removePanelOp(i.id),
|
||||
);
|
||||
ops.push(removeSectionOp(section.layoutIndex));
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, ops);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [section, dashboardId, onRefetch, showErrorModal]);
|
||||
|
||||
return { deleteSection, isSaving };
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { addSectionOp, titleUntitledSectionOp } from '../../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSectionV2[];
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
migrate: (newSectionTitle: string) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a free-flowing dashboard into a sectioned one: every existing
|
||||
* untitled layout that holds panels is titled in place ("Section 1", "Section
|
||||
* 2", …), then the brand-new section the user asked for is appended — all in one
|
||||
* atomic patch. Used once the user confirms the migration prompt.
|
||||
*/
|
||||
export function useFirstSectionMigration({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const migrate = useCallback(
|
||||
async (newSectionTitle: string): Promise<void> => {
|
||||
const trimmed = newSectionTitle.trim();
|
||||
if (!dashboardId || !trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
let counter = 1;
|
||||
sections.forEach((s) => {
|
||||
if (!s.title && s.items.length > 0) {
|
||||
ops.push(titleUntitledSectionOp(s.layoutIndex, `Section ${counter}`));
|
||||
counter += 1;
|
||||
}
|
||||
});
|
||||
ops.push(addSectionOp(trimmed));
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, ops);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
return { migrate, isSaving };
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { Layout } from 'react-grid-layout';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { replaceSectionItemsOp } from '../../../patchOps';
|
||||
import type { GridItemV2 } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
layoutIndex: number;
|
||||
items: GridItemV2[];
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
handleLayoutChange: (rglLayout: Layout[]) => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/** Maps an RGL layout back onto the section's grid items, preserving panel refs. */
|
||||
function mergeRglLayout(
|
||||
rglLayout: Layout[],
|
||||
items: GridItemV2[],
|
||||
): GridItemV2[] {
|
||||
const byId = new Map(items.map((item) => [item.id, item]));
|
||||
return rglLayout
|
||||
.map((entry) => {
|
||||
const existing = byId.get(entry.i);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
x: entry.x,
|
||||
y: entry.y,
|
||||
width: entry.w,
|
||||
height: entry.h,
|
||||
};
|
||||
})
|
||||
.filter((item): item is GridItemV2 => item !== null);
|
||||
}
|
||||
|
||||
function hasGeometryChanged(next: GridItemV2[], prev: GridItemV2[]): boolean {
|
||||
if (next.length !== prev.length) {
|
||||
return true;
|
||||
}
|
||||
const prevById = new Map(prev.map((item) => [item.id, item]));
|
||||
return next.some((item) => {
|
||||
const before = prevById.get(item.id);
|
||||
if (!before) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
before.x !== item.x ||
|
||||
before.y !== item.y ||
|
||||
before.width !== item.width ||
|
||||
before.height !== item.height
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists panel geometry within a single section. Call the returned handler
|
||||
* from RGL's `onDragStop`/`onResizeStop` (stop events only — not continuous
|
||||
* `onLayoutChange`) to limit network churn.
|
||||
*/
|
||||
export function usePersistLayout({
|
||||
layoutIndex,
|
||||
items,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
async (rglLayout: Layout[]): Promise<void> => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
const nextItems = mergeRglLayout(rglLayout, items);
|
||||
if (!hasGeometryChanged(nextItems, items)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, [
|
||||
replaceSectionItemsOp(layoutIndex, nextItems),
|
||||
]);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, items, layoutIndex, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
return { handleLayoutChange, isSaving };
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { renameSectionOp } from '../../../patchOps';
|
||||
|
||||
interface Params {
|
||||
layoutIndex: number;
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
rename: (title: string) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/** Renames a section's title via `replace /spec/layouts/<i>/spec/display/title`. */
|
||||
export function useRenameSection({
|
||||
layoutIndex,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const rename = useCallback(
|
||||
async (title: string): Promise<boolean> => {
|
||||
const trimmed = title.trim();
|
||||
if (!dashboardId || !trimmed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, [
|
||||
renameSectionOp(layoutIndex, trimmed),
|
||||
]);
|
||||
onRefetch();
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, layoutIndex, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
return { rename, isSaving };
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { reorderLayoutsOp } from '../../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSectionV2[];
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
sensors: ReturnType<typeof useSensors>;
|
||||
/** Display order — optimistically reordered on drop so the UI doesn't wait on refetch. */
|
||||
orderedSections: DashboardSectionV2[];
|
||||
/** The section currently being dragged (for the DragOverlay preview), or null. */
|
||||
activeSection: DashboardSectionV2 | null;
|
||||
onDragStart: (event: DragStartEvent) => void;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
onDragCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns section-reorder drag state. Reorders happen optimistically in local
|
||||
* state (keyed by stable section id) and persist via a single
|
||||
* `replace /spec/layouts` patch; the optimistic order is cleared once fresh
|
||||
* server data arrives. Each section maps 1:1 to a Grid layout via `layoutIndex`,
|
||||
* so the new layouts array is rebuilt by mapping the reordered sections back.
|
||||
*/
|
||||
export function useSectionDragReorder({
|
||||
sections,
|
||||
layouts,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [localOrderIds, setLocalOrderIds] = useState<string[] | null>(null);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
// Server data is the source of truth — drop optimistic order whenever it changes.
|
||||
useEffect(() => {
|
||||
setLocalOrderIds(null);
|
||||
}, [sections]);
|
||||
|
||||
const orderedSections = useMemo<DashboardSectionV2[]>(() => {
|
||||
if (!localOrderIds) {
|
||||
return sections;
|
||||
}
|
||||
const byId = new Map(sections.map((s) => [s.id, s]));
|
||||
const ordered = localOrderIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter((s): s is DashboardSectionV2 => s !== undefined);
|
||||
return ordered.length === sections.length ? ordered : sections;
|
||||
}, [sections, localOrderIds]);
|
||||
|
||||
const onDragStart = useCallback((event: DragStartEvent): void => {
|
||||
setActiveId(String(event.active.id));
|
||||
}, []);
|
||||
|
||||
const onDragCancel = useCallback((): void => {
|
||||
setActiveId(null);
|
||||
}, []);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
async (event: DragEndEvent): Promise<void> => {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id || !dashboardId || !layouts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldIndex = orderedSections.findIndex((s) => s.id === active.id);
|
||||
const newIndex = orderedSections.findIndex((s) => s.id === over.id);
|
||||
if (oldIndex < 0 || newIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newOrdered = arrayMove(orderedSections, oldIndex, newIndex);
|
||||
setLocalOrderIds(newOrdered.map((s) => s.id));
|
||||
|
||||
const newLayouts = newOrdered
|
||||
.map((s) => layouts[s.layoutIndex])
|
||||
.filter((l): l is DashboardtypesLayoutDTO => l !== undefined);
|
||||
|
||||
try {
|
||||
await patchDashboardV2({ id: dashboardId }, [reorderLayoutsOp(newLayouts)]);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
setLocalOrderIds(null); // revert optimistic order on failure
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[orderedSections, layouts, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
const activeSection = useMemo(
|
||||
() => orderedSections.find((s) => s.id === activeId) ?? null,
|
||||
[orderedSections, activeId],
|
||||
);
|
||||
|
||||
return {
|
||||
sensors,
|
||||
orderedSections,
|
||||
activeSection,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragCancel,
|
||||
};
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { setSectionCollapseOp } from '../../../patchOps';
|
||||
|
||||
interface Params {
|
||||
layoutIndex: number;
|
||||
initialOpen: boolean;
|
||||
dashboardId: string | undefined;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
open: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns a section's expand/collapse state. The toggle is optimistic (snappy UI)
|
||||
* and persists to `spec.layouts[i].spec.display.collapse.open` in the
|
||||
* background — no refetch, since collapse is a lightweight UI preference and a
|
||||
* full dashboard refetch would flicker. Reverts on patch failure.
|
||||
*/
|
||||
export function useToggleSectionCollapse({
|
||||
layoutIndex,
|
||||
initialOpen,
|
||||
dashboardId,
|
||||
}: Params): Result {
|
||||
const [open, setOpen] = useState<boolean>(initialOpen);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
// Reconcile with server state when it changes (e.g. after a refetch).
|
||||
useEffect(() => {
|
||||
setOpen(initialOpen);
|
||||
}, [initialOpen]);
|
||||
|
||||
const toggle = useCallback((): void => {
|
||||
setOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (dashboardId) {
|
||||
patchDashboardV2({ id: dashboardId }, [
|
||||
setSectionCollapseOp(layoutIndex, next),
|
||||
]).catch((error) => {
|
||||
setOpen(prev); // revert on failure
|
||||
showErrorModal(error as APIError);
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [dashboardId, layoutIndex, showErrorModal]);
|
||||
|
||||
return { open, toggle };
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Empty } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
DashboardtypesLayoutDTO,
|
||||
DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { layoutsToSections } from '../utils';
|
||||
import AddSectionControl from './Section/AddSectionControl/AddSectionControl';
|
||||
import Section from './Section/Section/Section';
|
||||
import SectionList from './Section/SectionList';
|
||||
import styles from './GridCardLayoutV2.module.scss';
|
||||
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
interface Props {
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined;
|
||||
dashboardId: string | undefined;
|
||||
isEditable: boolean;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function GridCardLayoutV2({
|
||||
layouts,
|
||||
panels,
|
||||
dashboardId,
|
||||
isEditable,
|
||||
onRefetch,
|
||||
}: Props): JSX.Element {
|
||||
const sections = useMemo(
|
||||
() => layoutsToSections(layouts, panels),
|
||||
[layouts, panels],
|
||||
);
|
||||
|
||||
const isEmpty =
|
||||
sections.length === 0 || sections.every((s) => s.items.length === 0);
|
||||
|
||||
// Sectioned mode = at least one titled layout. Sections then become a
|
||||
// draggable, reorderable list; otherwise the dashboard is a single
|
||||
// free-flowing grid with no section chrome or reordering.
|
||||
const isSectioned = useMemo(() => sections.some((s) => !!s.title), [sections]);
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<Typography.Text>No panels in this dashboard yet</Typography.Text>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSectioned) {
|
||||
return (
|
||||
<SectionList
|
||||
sections={sections}
|
||||
layouts={layouts}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{sections.map((section) => (
|
||||
<Section
|
||||
key={section.id}
|
||||
section={section}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderContent()}
|
||||
{isEditable ? (
|
||||
<AddSectionControl
|
||||
sections={sections}
|
||||
layouts={layouts}
|
||||
dashboardId={dashboardId}
|
||||
isSectioned={isSectioned}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default GridCardLayoutV2;
|
||||
@@ -1,63 +0,0 @@
|
||||
.dashboard-breadcrumbs {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
max-width: 80%;
|
||||
|
||||
.dashboard-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
padding: 0px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dashboard-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.id-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0px 2px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
height: 20px;
|
||||
|
||||
max-width: calc(100% - 120px);
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
}
|
||||
.id-btn:hover {
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
color: var(--bg-robin-300);
|
||||
}
|
||||
|
||||
.dashboard-icon-image {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import getSessionStorageApi from 'api/browser/sessionstorage/get';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { LayoutGrid } from '@signozhq/icons';
|
||||
|
||||
import { Base64Icons } from '../../../DashboardContainer/DashboardSettings/General/utils';
|
||||
|
||||
import './DashboardBreadcrumbs.styles.scss';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
function DashboardBreadcrumbs({ title, image }: Props): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const goToListPage = useCallback(() => {
|
||||
const dashboardsListQueryParamsString = getSessionStorageApi(
|
||||
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
|
||||
);
|
||||
|
||||
if (dashboardsListQueryParamsString) {
|
||||
safeNavigate({
|
||||
pathname: ROUTES.ALL_DASHBOARD,
|
||||
search: `?${dashboardsListQueryParamsString}`,
|
||||
});
|
||||
} else {
|
||||
safeNavigate(ROUTES.ALL_DASHBOARD);
|
||||
}
|
||||
}, [safeNavigate]);
|
||||
|
||||
return (
|
||||
<div className="dashboard-breadcrumbs">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LayoutGrid size={14} />}
|
||||
className="dashboard-btn"
|
||||
onClick={goToListPage}
|
||||
>
|
||||
Dashboard /
|
||||
</Button>
|
||||
<Button type="text" className="id-btn dashboard-name-btn">
|
||||
<img
|
||||
src={image || Base64Icons[0]}
|
||||
alt="dashboard-icon"
|
||||
className="dashboard-icon-image"
|
||||
/>
|
||||
{title}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardBreadcrumbs;
|
||||
@@ -1,9 +0,0 @@
|
||||
.dashboard-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
|
||||
|
||||
import './DashboardHeader.styles.scss';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
function DashboardHeader({ title, image }: Props): JSX.Element {
|
||||
return (
|
||||
<div className="dashboard-header">
|
||||
<DashboardBreadcrumbs title={title} image={image} />
|
||||
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DashboardHeader);
|
||||
@@ -1,46 +0,0 @@
|
||||
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
|
||||
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import DashboardDescriptionV2 from './DashboardDescriptionV2';
|
||||
import GridCardLayoutV2 from './GridCardLayoutV2';
|
||||
import type { V2Dashboard } from './utils';
|
||||
import styles from './DashboardContainerV2.module.scss';
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function DashboardContainerV2({ dashboard, onRefetch }: Props): JSX.Element {
|
||||
const fullScreenHandle = useFullScreenHandle();
|
||||
const spec = dashboard?.spec;
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const isEditable = !dashboard?.locked && editDashboard;
|
||||
|
||||
return (
|
||||
<FullScreen handle={fullScreenHandle}>
|
||||
<div className={styles.container}>
|
||||
<DashboardDescriptionV2
|
||||
dashboard={dashboard}
|
||||
handle={fullScreenHandle}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
<div className={styles.body}>
|
||||
<GridCardLayoutV2
|
||||
layouts={spec?.layouts}
|
||||
panels={spec?.panels ?? undefined}
|
||||
dashboardId={dashboard?.id}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FullScreen>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardContainerV2;
|
||||
@@ -1,189 +0,0 @@
|
||||
import type {
|
||||
DashboardGridItemDTO,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
DashboardtypesLayoutDTO,
|
||||
DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesJSONPatchOperationDTOOp } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { GridItemV2 } from './utils';
|
||||
|
||||
/**
|
||||
* Pure RFC-6902 JSON-Patch builders for the V2 dashboard spec. These are
|
||||
* intentionally side-effect-free (no React, no network) so they can be unit
|
||||
* tested and reused by the layout hooks. JSON pointers target the postable
|
||||
* shape: `/spec/layouts/...`, `/spec/panels/...` (matches the existing V2
|
||||
* patches in DashboardSettings/General and DashboardDescriptionV2).
|
||||
*/
|
||||
|
||||
const { add, replace, remove } = DashboardtypesJSONPatchOperationDTOOp;
|
||||
|
||||
const PANEL_REF_PREFIX = '#/spec/panels/';
|
||||
|
||||
export function panelRef(panelId: string): string {
|
||||
return `${PANEL_REF_PREFIX}${panelId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a minimal, backend-valid panel for a given plugin kind. The spec
|
||||
* requires exactly one query whose plugin kind is allowed for the panel;
|
||||
* `signoz/BuilderQuery` is allowed for every panel kind and its contents are not
|
||||
* validated, so an empty builder query is the safe default. The real query is
|
||||
* filled in once the panel editor lands.
|
||||
*/
|
||||
export function createDefaultPanel(pluginKind: string): DashboardtypesPanelDTO {
|
||||
// The DTO types plugin/query kinds as large generated enum unions; the kind
|
||||
// here is chosen dynamically by the user, so we build the structurally-valid
|
||||
// shape and assert the type.
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'New panel' },
|
||||
plugin: { kind: pluginKind, spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'TimeSeriesQuery',
|
||||
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { name: 'A' } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
/** Converts a UI grid item back into the spec's grid-item DTO shape. */
|
||||
export function gridItemToDTO(item: GridItemV2): DashboardGridItemDTO {
|
||||
return {
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
content: { $ref: panelRef(item.id) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Replace the entire items array of one section (used on panel move/resize). */
|
||||
export function replaceSectionItemsOp(
|
||||
layoutIndex: number,
|
||||
items: GridItemV2[],
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return {
|
||||
op: replace,
|
||||
path: `/spec/layouts/${layoutIndex}/spec/items`,
|
||||
value: items.map(gridItemToDTO),
|
||||
};
|
||||
}
|
||||
|
||||
/** Replace the whole layouts array (used on section reorder — avoids move-index ambiguity). */
|
||||
export function reorderLayoutsOp(
|
||||
layouts: DashboardtypesLayoutDTO[],
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return { op: replace, path: '/spec/layouts', value: layouts };
|
||||
}
|
||||
|
||||
/** An empty titled Grid layout (one section). */
|
||||
export function newGridLayout(title: string): DashboardtypesLayoutDTO {
|
||||
return {
|
||||
kind: 'Grid' as DashboardtypesLayoutDTO['kind'],
|
||||
spec: { display: { title, collapse: { open: true } }, items: [] },
|
||||
};
|
||||
}
|
||||
|
||||
/** Append a new, empty titled Grid section. */
|
||||
export function addSectionOp(
|
||||
title: string,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return { op: add, path: '/spec/layouts/-', value: newGridLayout(title) };
|
||||
}
|
||||
|
||||
interface AddPanelToSectionArgs {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
layoutIndex: number;
|
||||
item: DashboardGridItemDTO;
|
||||
}
|
||||
|
||||
/** Add a panel to `spec.panels` and an item ref into a section, as one atomic patch. */
|
||||
export function addPanelToSectionOps({
|
||||
panelId,
|
||||
panel,
|
||||
layoutIndex,
|
||||
item,
|
||||
}: AddPanelToSectionArgs): DashboardtypesJSONPatchOperationDTO[] {
|
||||
return [
|
||||
{ op: add, path: `/spec/panels/${panelId}`, value: panel },
|
||||
{ op: add, path: `/spec/layouts/${layoutIndex}/spec/items/-`, value: item },
|
||||
];
|
||||
}
|
||||
|
||||
interface MovePanelArgs {
|
||||
sourceIndex: number;
|
||||
sourceItems: GridItemV2[];
|
||||
targetIndex: number;
|
||||
targetItems: GridItemV2[];
|
||||
}
|
||||
|
||||
/** Move a panel's item ref from one section to another (panel stays in spec.panels). */
|
||||
export function movePanelBetweenSectionsOps({
|
||||
sourceIndex,
|
||||
sourceItems,
|
||||
targetIndex,
|
||||
targetItems,
|
||||
}: MovePanelArgs): DashboardtypesJSONPatchOperationDTO[] {
|
||||
return [
|
||||
replaceSectionItemsOp(sourceIndex, sourceItems),
|
||||
replaceSectionItemsOp(targetIndex, targetItems),
|
||||
];
|
||||
}
|
||||
|
||||
/** Rename an existing section's title. */
|
||||
export function renameSectionOp(
|
||||
layoutIndex: number,
|
||||
title: string,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return {
|
||||
op: replace,
|
||||
path: `/spec/layouts/${layoutIndex}/spec/display/title`,
|
||||
value: title,
|
||||
};
|
||||
}
|
||||
|
||||
/** Persist a section's collapse state. `add` safely replaces the collapse object if present. */
|
||||
export function setSectionCollapseOp(
|
||||
layoutIndex: number,
|
||||
open: boolean,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return {
|
||||
op: add,
|
||||
path: `/spec/layouts/${layoutIndex}/spec/display/collapse`,
|
||||
value: { open },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* First-section migration: give an existing untitled (free-flowing) layout a
|
||||
* title, turning it into a section in place while preserving its panels.
|
||||
*/
|
||||
export function titleUntitledSectionOp(
|
||||
layoutIndex: number,
|
||||
title: string,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return {
|
||||
op: add,
|
||||
path: `/spec/layouts/${layoutIndex}/spec/display`,
|
||||
value: { title, collapse: { open: true } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove a section. Panel cleanup (orphaned refs) is handled by the caller. */
|
||||
export function removeSectionOp(
|
||||
layoutIndex: number,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return { op: remove, path: `/spec/layouts/${layoutIndex}` };
|
||||
}
|
||||
|
||||
/** Remove a panel definition from `spec.panels`. */
|
||||
export function removePanelOp(
|
||||
panelId: string,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return { op: remove, path: `/spec/panels/${panelId}` };
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesLayoutDTO,
|
||||
DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type V2Dashboard = DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
export interface GridItemV2 {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
}
|
||||
|
||||
const PANEL_REF_PREFIX = '#/spec/panels/';
|
||||
|
||||
export function extractPanelIdFromRef(ref: string | undefined): string | null {
|
||||
if (!ref) {
|
||||
return null;
|
||||
}
|
||||
if (!ref.startsWith(PANEL_REF_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
return ref.slice(PANEL_REF_PREFIX.length);
|
||||
}
|
||||
|
||||
export function flattenGridLayout(
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null,
|
||||
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
|
||||
): GridItemV2[] {
|
||||
if (!layouts?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: GridItemV2[] = [];
|
||||
layouts.forEach((layoutEnvelope) => {
|
||||
if (layoutEnvelope?.kind !== 'Grid') {
|
||||
return;
|
||||
}
|
||||
const gridItems = layoutEnvelope.spec?.items ?? [];
|
||||
gridItems.forEach((item) => {
|
||||
const id = extractPanelIdFromRef(item.content?.$ref);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
items.push({
|
||||
id,
|
||||
x: item.x ?? 0,
|
||||
y: item.y ?? 0,
|
||||
width: item.width ?? 6,
|
||||
height: item.height ?? 6,
|
||||
panel: panels?.[id],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* A section corresponds to one entry in `spec.layouts`. If the Grid has a
|
||||
* `display.title`, it renders with a collapsible header; otherwise it is a
|
||||
* "default" untitled section (visually just the grid).
|
||||
*/
|
||||
export interface DashboardSectionV2 {
|
||||
/**
|
||||
* Stable identity used for React keys and dnd-kit sortable item ids. Derived
|
||||
* from the section's content (its first panel ref) so it survives reordering
|
||||
* — unlike the positional `layoutIndex`. See `getSectionStableId`.
|
||||
*/
|
||||
id: string;
|
||||
/** Position of this section's Grid in `spec.layouts`. All JSON-Patch ops target by this. */
|
||||
layoutIndex: number;
|
||||
title: string | undefined;
|
||||
open: boolean;
|
||||
items: GridItemV2[];
|
||||
repeatVariable: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a stable id for a section from its content. Reordering sections changes
|
||||
* their `layoutIndex` but not their content, so keying off the first panel ref
|
||||
* keeps React component instances (and any local state) bound to the right
|
||||
* section across a reorder. Empty sections fall back to a positional id — they
|
||||
* are rarely reordered, and a future backend `id` on the layout spec is the
|
||||
* proper long-term fix.
|
||||
*/
|
||||
export function getSectionStableId(
|
||||
items: GridItemV2[],
|
||||
layoutIndex: number,
|
||||
): string {
|
||||
if (items.length > 0) {
|
||||
return `sec-${items[0].id}`;
|
||||
}
|
||||
return `sec-empty-${layoutIndex}`;
|
||||
}
|
||||
|
||||
export function layoutsToSections(
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null,
|
||||
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
|
||||
): DashboardSectionV2[] {
|
||||
if (!layouts?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return layouts
|
||||
.map((layoutEnvelope, idx) => {
|
||||
if (layoutEnvelope?.kind !== 'Grid') {
|
||||
return null;
|
||||
}
|
||||
const spec = layoutEnvelope.spec;
|
||||
const items: GridItemV2[] = (spec?.items ?? [])
|
||||
.map((item) => {
|
||||
const id = extractPanelIdFromRef(item.content?.$ref);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
x: item.x ?? 0,
|
||||
y: item.y ?? 0,
|
||||
width: item.width ?? 6,
|
||||
height: item.height ?? 6,
|
||||
panel: panels?.[id],
|
||||
};
|
||||
})
|
||||
.filter((it): it is GridItemV2 => it !== null);
|
||||
|
||||
const title = spec?.display?.title;
|
||||
// `open` defaults to true when no collapse field is set (the section
|
||||
// is expanded by default).
|
||||
const open = spec?.display?.collapse?.open !== false;
|
||||
|
||||
return {
|
||||
id: getSectionStableId(items, idx),
|
||||
layoutIndex: idx,
|
||||
title,
|
||||
open,
|
||||
items,
|
||||
repeatVariable: spec?.repeatVariable,
|
||||
};
|
||||
})
|
||||
.filter((s): s is DashboardSectionV2 => s !== null);
|
||||
}
|
||||
|
||||
export function getPanelKindLabel(
|
||||
panel: DashboardtypesPanelDTO | undefined,
|
||||
): string {
|
||||
const kind = panel?.spec?.plugin?.kind;
|
||||
if (!kind) {
|
||||
return 'unknown';
|
||||
}
|
||||
return kind.replace(/^signoz\//, '');
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import QueryStatus from './QueryStatus';
|
||||
|
||||
function LogsActionsContainer({
|
||||
listQuery,
|
||||
selectedPanelType,
|
||||
@@ -16,6 +18,10 @@ function LogsActionsContainer({
|
||||
handleToggleFrequencyChart,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
isFetching,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
}: {
|
||||
listQuery: any;
|
||||
selectedPanelType: PANEL_TYPES;
|
||||
@@ -23,6 +29,10 @@ function LogsActionsContainer({
|
||||
handleToggleFrequencyChart: () => void;
|
||||
orderBy: string;
|
||||
setOrderBy: (value: string) => void;
|
||||
isFetching: boolean;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isSuccess: boolean;
|
||||
}): JSX.Element {
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
@@ -96,6 +106,17 @@ function LogsActionsContainer({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(selectedPanelType === PANEL_TYPES.TIME_SERIES ||
|
||||
selectedPanelType === PANEL_TYPES.TABLE) && (
|
||||
<div className="query-stats">
|
||||
<QueryStatus
|
||||
loading={isLoading || isFetching}
|
||||
error={isError}
|
||||
success={isSuccess}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -155,6 +155,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
.query-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
align-self: flex-end;
|
||||
|
||||
.rows {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.36px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.time {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.36px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
border: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.query-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
49
frontend/src/container/LogsExplorerViews/QueryStatus.tsx
Normal file
49
frontend/src/container/LogsExplorerViews/QueryStatus.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { LoaderCircle, CircleCheck } from '@signozhq/icons';
|
||||
import { Spin } from 'antd';
|
||||
|
||||
import solidXCircleUrl from '@/assets/Icons/solid-x-circle.svg';
|
||||
|
||||
import './QueryStatus.styles.scss';
|
||||
|
||||
interface IQueryStatusProps {
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export default function QueryStatus(
|
||||
props: IQueryStatusProps,
|
||||
): React.ReactElement {
|
||||
const { loading, error, success } = props;
|
||||
|
||||
const content = useMemo((): React.ReactElement => {
|
||||
if (loading) {
|
||||
return (
|
||||
<Spin
|
||||
spinning
|
||||
size="small"
|
||||
indicator={<LoaderCircle className="animate-spin" size="md" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<img
|
||||
src={solidXCircleUrl}
|
||||
alt="header"
|
||||
className="error"
|
||||
style={{ height: '14px', width: '14px' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (success) {
|
||||
return (
|
||||
<CircleCheck className="success" size={14} fill={Color.BG_ROBIN_500} />
|
||||
);
|
||||
}
|
||||
return <div />;
|
||||
}, [error, loading, success]);
|
||||
return <div className="query-status">{content}</div>;
|
||||
}
|
||||
@@ -160,7 +160,7 @@ function LogsExplorerViewsContainer({
|
||||
'custom',
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } =
|
||||
const { data, isLoading, isFetching, isError, isSuccess, error } =
|
||||
useGetExplorerQueryRange(
|
||||
requestData,
|
||||
selectedPanelType,
|
||||
@@ -437,6 +437,10 @@ function LogsExplorerViewsContainer({
|
||||
handleToggleFrequencyChart={handleToggleFrequencyChart}
|
||||
orderBy={orderBy}
|
||||
setOrderBy={setOrderBy}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
isSuccess={isSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ function AllAttributes({
|
||||
);
|
||||
|
||||
const attributes = useMemo(
|
||||
() => attributesData?.data?.attributes ?? [],
|
||||
() => attributesData?.data.attributes ?? [],
|
||||
[attributesData],
|
||||
);
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ function MetricDetails({
|
||||
);
|
||||
|
||||
const metadata = useMemo(() => {
|
||||
if (!metricMetadataResponse?.data) {
|
||||
if (!metricMetadataResponse) {
|
||||
return null;
|
||||
}
|
||||
const { type, description, unit, temporality, isMonotonic } =
|
||||
|
||||
@@ -21,10 +21,6 @@
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
color: var(--semantic-primary-foreground);
|
||||
|
||||
&--error {
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
|
||||
.reset-password-header-title {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { CircleAlert } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import './ResetPassword.styles.scss';
|
||||
|
||||
interface TokenErrorContent {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
function getErrorContent(error?: APIError): TokenErrorContent {
|
||||
const code = error?.getErrorCode();
|
||||
|
||||
if (code === 'reset_password_token_expired') {
|
||||
return {
|
||||
title: 'Reset Password token is expired',
|
||||
subtitle:
|
||||
'Password reset links are single-use and expire after a set period. Please request a new password reset link.',
|
||||
};
|
||||
}
|
||||
|
||||
if (code === 'reset_password_token_not_found') {
|
||||
return {
|
||||
title: 'Invalid Reset Link',
|
||||
subtitle:
|
||||
'This reset password link is invalid or has already been used. Please request a new password reset link.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Reset Link Unavailable',
|
||||
subtitle:
|
||||
'We could not validate your reset password link. Please request a new one.',
|
||||
};
|
||||
}
|
||||
|
||||
interface TokenErrorProps {
|
||||
error?: APIError;
|
||||
}
|
||||
|
||||
function TokenError({ error }: TokenErrorProps): JSX.Element {
|
||||
const { title, subtitle } = getErrorContent(error);
|
||||
|
||||
return (
|
||||
<AuthPageContainer>
|
||||
<div className="reset-password-card reset-password-card--centered">
|
||||
<div className="reset-password-header">
|
||||
<div className="reset-password-header-icon reset-password-header-icon--error">
|
||||
<CircleAlert size={32} />
|
||||
</div>
|
||||
<Typography.Title level={4} className="reset-password-header-title">
|
||||
{title}
|
||||
</Typography.Title>
|
||||
<Typography.Text className="reset-password-header-subtitle">
|
||||
{subtitle}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{error && <AuthError error={error} />}
|
||||
</div>
|
||||
</AuthPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenError;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Logout } from 'api/utils';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
@@ -16,6 +17,10 @@ jest.mock('lib/history', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('api/utils', () => ({
|
||||
Logout: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockSuccessNotification = jest.fn();
|
||||
const mockErrorNotification = jest.fn();
|
||||
|
||||
@@ -65,6 +70,17 @@ describe('ResetPassword Component', () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/signoz 1\.0\.0/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to login when token is missing', () => {
|
||||
window.history.pushState({}, '', '/password-reset');
|
||||
|
||||
render(<ResetPassword version="1.0.0" />, undefined, {
|
||||
initialRoute: '/password-reset',
|
||||
});
|
||||
|
||||
expect(Logout).toHaveBeenCalled();
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-use';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { Form, Input as AntdInput } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Logout } from 'api/utils';
|
||||
import resetPasswordApi from 'api/v1/factor_password/resetPassword';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
@@ -37,6 +38,13 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
Logout();
|
||||
history.push(ROUTES.LOGIN);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const handleFormSubmit: () => Promise<void> = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
@@ -1,47 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import Spinner from 'components/Spinner';
|
||||
import DashboardContainerV2 from 'container/DashboardContainerV2';
|
||||
|
||||
function DashboardPageV2(): JSX.Element {
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = useGetDashboardV2({
|
||||
id: dashboardId,
|
||||
});
|
||||
|
||||
const dashboard = data?.data;
|
||||
const name = dashboard?.spec?.display?.name;
|
||||
|
||||
useEffect(() => {
|
||||
if (name) {
|
||||
document.title = name;
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Typography.Title>Failed to load dashboard</Typography.Title>
|
||||
<Typography.Text>{(error as Error)?.message}</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardContainerV2
|
||||
dashboard={dashboard}
|
||||
onRefetch={(): void => {
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <>DashboardPageV2</>;
|
||||
}
|
||||
|
||||
export default DashboardPageV2;
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { Logout } from 'api/utils';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { createErrorResponse, rest, server } from 'mocks-server/server';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ResetPassword from '../index';
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
push: jest.fn(),
|
||||
location: { search: '' },
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('api/utils', () => ({
|
||||
Logout: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const VERIFY_TOKEN_ENDPOINT = '*/api/v2/reset_password_tokens/verify';
|
||||
const VERSION_ENDPOINT = '*/version';
|
||||
|
||||
const mockHistoryPush = history.push as jest.MockedFunction<
|
||||
typeof history.push
|
||||
>;
|
||||
|
||||
const successVerifyResponse = {
|
||||
data: { id: 'token-id', token: 'valid-token' },
|
||||
};
|
||||
|
||||
const successVersionResponse = {
|
||||
version: '0.0.1',
|
||||
ee: 'Y',
|
||||
setupCompleted: true,
|
||||
};
|
||||
|
||||
describe('ResetPassword Page', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(successVersionResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('Token validation on page load', () => {
|
||||
it('shows spinner then form when token is valid', async () => {
|
||||
server.use(
|
||||
rest.post(VERIFY_TOKEN_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.delay(50), ctx.status(200), ctx.json(successVerifyResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
window.history.pushState({}, '', '/password-reset?token=valid-token');
|
||||
render(<ResetPassword />, undefined, {
|
||||
initialRoute: '/password-reset?token=valid-token',
|
||||
});
|
||||
|
||||
// Loading state: spinner visible, form and error absent
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('password')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/reset password token is expired/i),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// After verification resolves: form is shown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('password')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('confirmPassword')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Invalid Reset Link" when token is not found (404)', async () => {
|
||||
server.use(
|
||||
rest.post(
|
||||
VERIFY_TOKEN_ENDPOINT,
|
||||
createErrorResponse(
|
||||
404,
|
||||
'reset_password_token_not_found',
|
||||
'reset password token does not exist',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
window.history.pushState({}, '', '/password-reset?token=invalid-token');
|
||||
render(<ResetPassword />, undefined, {
|
||||
initialRoute: '/password-reset?token=invalid-token',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid reset link/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/invalid or has already been used/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/reset password token does not exist/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "token is expired" when token is expired (401) without redirecting to login', async () => {
|
||||
server.use(
|
||||
rest.post(
|
||||
VERIFY_TOKEN_ENDPOINT,
|
||||
createErrorResponse(
|
||||
401,
|
||||
'reset_password_token_expired',
|
||||
'reset password token has expired',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
window.history.pushState({}, '', '/password-reset?token=expired-token');
|
||||
render(<ResetPassword />, undefined, {
|
||||
initialRoute: '/password-reset?token=expired-token',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/reset password token is expired/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/single-use and expire after a set period/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/reset password token has expired/i),
|
||||
).toBeInTheDocument();
|
||||
// 401 from this endpoint must NOT trigger logout/redirect
|
||||
expect(mockHistoryPush).not.toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
expect(Logout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redirects to login when no token is in the URL', async () => {
|
||||
window.history.pushState({}, '', '/password-reset');
|
||||
render(<ResetPassword />, undefined, {
|
||||
initialRoute: '/password-reset',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
});
|
||||
|
||||
expect(Logout).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,8 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useLocation } from 'react-use';
|
||||
import { AxiosError } from 'axios';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import getUserVersion from 'api/v1/version/get';
|
||||
import { verifyResetPasswordToken } from 'api/generated/services/users';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Logout } from 'api/utils';
|
||||
import Spinner from 'components/Spinner';
|
||||
import ResetPasswordContainer from 'container/ResetPassword';
|
||||
import TokenError from 'container/ResetPassword/TokenError';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -19,65 +10,24 @@ import APIError from 'types/api/error';
|
||||
function ResetPassword(): JSX.Element {
|
||||
const { user, isLoggedIn } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { search } = useLocation();
|
||||
const params = new URLSearchParams(search || '');
|
||||
const token = params.get('token') || '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
void Logout();
|
||||
history.push(ROUTES.LOGIN);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const {
|
||||
data: versionData,
|
||||
isLoading: isVersionLoading,
|
||||
error: versionError,
|
||||
} = useQuery({
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryFn: getUserVersion,
|
||||
queryKey: ['getUserVersion', user?.accessJwt],
|
||||
enabled: !isLoggedIn,
|
||||
});
|
||||
|
||||
const {
|
||||
isLoading: isVerifying,
|
||||
isError: isTokenError,
|
||||
error: tokenError,
|
||||
} = useQuery<
|
||||
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
|
||||
AxiosError<RenderErrorResponseDTO>
|
||||
>({
|
||||
queryFn: () => verifyResetPasswordToken({ token }),
|
||||
queryKey: ['verifyResetPasswordToken', token],
|
||||
enabled: !!token,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const tokenApiError = useMemo(
|
||||
() => convertToApiError(tokenError),
|
||||
[tokenError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (versionError) {
|
||||
showErrorModal(versionError as APIError);
|
||||
if (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}, [versionError, showErrorModal]);
|
||||
}, [error, showErrorModal]);
|
||||
|
||||
if (!token) {
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading..." />;
|
||||
}
|
||||
|
||||
if (isVersionLoading || isVerifying) {
|
||||
return <Spinner tip="Validating your reset password token..." />;
|
||||
}
|
||||
|
||||
if (isTokenError) {
|
||||
return <TokenError error={tokenApiError} />;
|
||||
}
|
||||
|
||||
return <ResetPasswordContainer version={versionData?.data.version || ''} />;
|
||||
return <ResetPasswordContainer version={data?.data.version || ''} />;
|
||||
}
|
||||
|
||||
export default ResetPassword;
|
||||
|
||||
@@ -22,7 +22,6 @@ import styles from './AnalyticsPanel.module.scss';
|
||||
interface AnalyticsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onTabChange: (tab: string) => void;
|
||||
}
|
||||
|
||||
const PANEL_WIDTH = 350;
|
||||
@@ -33,7 +32,6 @@ const PANEL_MARGIN_BOTTOM = 50;
|
||||
function AnalyticsPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
onTabChange,
|
||||
}: AnalyticsPanelProps): JSX.Element | null {
|
||||
const aggregations = useTraceStore((s) => s.aggregations);
|
||||
const colorByFieldName = useTraceStore((s) => s.colorByField.name);
|
||||
@@ -120,7 +118,7 @@ function AnalyticsPanel({
|
||||
/>
|
||||
|
||||
<div className={styles.body}>
|
||||
<TabsRoot defaultValue="exec-time" onValueChange={onTabChange}>
|
||||
<TabsRoot defaultValue="exec-time">
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="exec-time" variant="secondary">
|
||||
% exec time
|
||||
|
||||
@@ -31,12 +31,7 @@ import Events from 'container/SpanDetailsDrawer/Events/Events';
|
||||
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
TraceDetailEventKeys,
|
||||
TraceDetailEvents,
|
||||
} from 'pages/TraceDetailsV3/events';
|
||||
import { useMigratePinnedAttributes } from 'pages/TraceDetailsV3/hooks/useMigratePinnedAttributes';
|
||||
import { useTraceDetailLogEvent } from 'pages/TraceDetailsV3/hooks/useTraceDetailLogEvent';
|
||||
import {
|
||||
getSpanAttribute,
|
||||
getSpanDisplayData,
|
||||
@@ -91,16 +86,6 @@ function SpanDetailsContent({
|
||||
}): JSX.Element {
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
const spanAttributeActions = useSpanAttributeActions();
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', selectedSpan.trace_id);
|
||||
const handleTabChange = useCallback(
|
||||
(tab: string): void => {
|
||||
logTraceEvent(TraceDetailEvents.SpanPanelTabChanged, {
|
||||
[TraceDetailEventKeys.Tab]: tab,
|
||||
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
|
||||
});
|
||||
},
|
||||
[logTraceEvent, selectedSpan.span_id],
|
||||
);
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
|
||||
@@ -391,7 +376,7 @@ function SpanDetailsContent({
|
||||
|
||||
<div className={styles.tabsSection}>
|
||||
{/* Step 9: ContentTabs */}
|
||||
<TabsRoot defaultValue="overview" onValueChange={handleTabChange}>
|
||||
<TabsRoot defaultValue="overview">
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="overview" variant="secondary">
|
||||
<Bookmark size={14} /> Overview
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
@@ -29,8 +29,6 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
@@ -92,35 +90,11 @@ function TraceDetailsHeader({
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceID || '');
|
||||
const pageLoadedAtRef = useRef(Date.now());
|
||||
|
||||
const handleSwitchToOldView = useCallback((): void => {
|
||||
logTraceEvent(TraceDetailEvents.ViewSwitched, {
|
||||
[TraceDetailEventKeys.From]: 'v3',
|
||||
[TraceDetailEventKeys.To]: 'v2',
|
||||
[TraceDetailEventKeys.DwellMs]: Date.now() - pageLoadedAtRef.current,
|
||||
});
|
||||
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW, 'true');
|
||||
const oldUrl = `/trace-old/${traceID}${window.location.search}`;
|
||||
history.replace(oldUrl);
|
||||
}, [traceID, logTraceEvent]);
|
||||
|
||||
const handleToggleAnalytics = useCallback((): void => {
|
||||
logTraceEvent(TraceDetailEvents.AnalyticsPanelToggled, {
|
||||
[TraceDetailEventKeys.Open]: !isAnalyticsOpen,
|
||||
});
|
||||
setIsAnalyticsOpen((prev) => !prev);
|
||||
}, [logTraceEvent, isAnalyticsOpen]);
|
||||
|
||||
const handleAnalyticsTabChange = useCallback(
|
||||
(tab: string): void => {
|
||||
logTraceEvent(TraceDetailEvents.AnalyticsTabChanged, {
|
||||
[TraceDetailEventKeys.Tab]: tab,
|
||||
});
|
||||
},
|
||||
[logTraceEvent],
|
||||
);
|
||||
}, [traceID]);
|
||||
|
||||
const handlePreviousBtnClick = useCallback((): void => {
|
||||
if (hasInAppHistory()) {
|
||||
@@ -193,7 +167,7 @@ function TraceDetailsHeader({
|
||||
size="icon"
|
||||
color="secondary"
|
||||
aria-label="Analytics"
|
||||
onClick={handleToggleAnalytics}
|
||||
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
|
||||
>
|
||||
<ChartPie size={14} />
|
||||
</Button>
|
||||
@@ -271,7 +245,6 @@ function TraceDetailsHeader({
|
||||
<AnalyticsPanel
|
||||
isOpen={isAnalyticsOpen}
|
||||
onClose={(): void => setIsAnalyticsOpen(false)}
|
||||
onTabChange={handleAnalyticsTabChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
export enum TraceDetailEvents {
|
||||
DataLoaded = 'Trace Detail: Data loaded',
|
||||
ViewSwitched = 'Trace Detail: View switched',
|
||||
FlameGraphToggled = 'Trace Detail: Flame graph toggled',
|
||||
WaterfallToggled = 'Trace Detail: Waterfall toggled',
|
||||
AnalyticsPanelToggled = 'Trace Detail: Analytics panel toggled',
|
||||
AnalyticsTabChanged = 'Trace Detail: Analytics tab changed',
|
||||
SpanPanelTabChanged = 'Trace Detail: Span panel tab changed',
|
||||
}
|
||||
|
||||
export enum TraceDetailEventKeys {
|
||||
// Injected on every event by useTraceDetailLogEvent
|
||||
View = 'view',
|
||||
TraceId = 'traceId',
|
||||
// Data loaded — trace shape
|
||||
TotalSpansCount = 'totalSpansCount',
|
||||
NumServices = 'numServices',
|
||||
TraceDurationMs = 'traceDurationMs',
|
||||
HadErrors = 'hadErrors',
|
||||
FlamegraphSampled = 'flamegraphSampled',
|
||||
// Data loaded — persisted settings
|
||||
SpanPanelVariant = 'spanPanelVariant',
|
||||
ColorByField = 'colorByField',
|
||||
PreviewFieldsCount = 'previewFieldsCount',
|
||||
EntryPreferOldView = 'entryPreferOldView',
|
||||
// View switched
|
||||
From = 'from',
|
||||
To = 'to',
|
||||
DwellMs = 'dwellMs',
|
||||
// Toggles / tabs
|
||||
Expanded = 'expanded',
|
||||
Open = 'open',
|
||||
Tab = 'tab',
|
||||
// Span panel tab changed
|
||||
SpanId = 'spanId',
|
||||
}
|
||||
|
||||
export type TraceDetailView = 'v2' | 'v3';
|
||||
@@ -1,88 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { TraceDetailEvents } from '../../events';
|
||||
import { useTraceDetailLogEvent } from '../useTraceDetailLogEvent';
|
||||
|
||||
const logEventMock = jest.fn();
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): void => logEventMock(...args),
|
||||
}));
|
||||
|
||||
describe('useTraceDetailLogEvent', () => {
|
||||
beforeEach(() => {
|
||||
logEventMock.mockClear();
|
||||
});
|
||||
|
||||
it('injects view and traceId on every event', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTraceDetailLogEvent('v3', 'trace-123'),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(TraceDetailEvents.DataLoaded, { totalSpansCount: 42 });
|
||||
});
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(logEventMock).toHaveBeenCalledWith(TraceDetailEvents.DataLoaded, {
|
||||
view: 'v3',
|
||||
traceId: 'trace-123',
|
||||
totalSpansCount: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('injects view and traceId even when no attributes are passed', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTraceDetailLogEvent('v2', 'trace-456'),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(TraceDetailEvents.ViewSwitched);
|
||||
});
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(TraceDetailEvents.ViewSwitched, {
|
||||
view: 'v2',
|
||||
traceId: 'trace-456',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a stable callback identity and emits the latest traceId', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ traceId }) => useTraceDetailLogEvent('v3', traceId),
|
||||
{ initialProps: { traceId: 'trace-1' } },
|
||||
);
|
||||
|
||||
const firstIdentity = result.current;
|
||||
rerender({ traceId: 'trace-2' });
|
||||
|
||||
expect(result.current).toBe(firstIdentity);
|
||||
|
||||
act(() => {
|
||||
result.current(TraceDetailEvents.SpanPanelTabChanged, { spanId: 's1' });
|
||||
});
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
TraceDetailEvents.SpanPanelTabChanged,
|
||||
{
|
||||
view: 'v3',
|
||||
traceId: 'trace-2',
|
||||
spanId: 's1',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('never throws if logEvent throws (analytics must not break the UI)', () => {
|
||||
logEventMock.mockImplementationOnce(() => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useTraceDetailLogEvent('v3', 'trace-123'),
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current(TraceDetailEvents.DataLoaded);
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
import {
|
||||
TraceDetailEventKeys,
|
||||
TraceDetailEvents,
|
||||
TraceDetailView,
|
||||
} from '../events';
|
||||
|
||||
export type TraceDetailLogEvent = (
|
||||
event: TraceDetailEvents,
|
||||
attributes?: Record<string, unknown>,
|
||||
) => void;
|
||||
|
||||
export function useTraceDetailLogEvent(
|
||||
view: TraceDetailView,
|
||||
traceId: string,
|
||||
): TraceDetailLogEvent {
|
||||
const contextRef = useRef({ view, traceId });
|
||||
contextRef.current = { view, traceId };
|
||||
|
||||
return useCallback(
|
||||
(
|
||||
event: TraceDetailEvents,
|
||||
attributes: Record<string, unknown> = {},
|
||||
): void => {
|
||||
try {
|
||||
void logEvent(event, {
|
||||
[TraceDetailEventKeys.View]: contextRef.current.view,
|
||||
[TraceDetailEventKeys.TraceId]: contextRef.current.traceId,
|
||||
...attributes,
|
||||
});
|
||||
} catch {
|
||||
// No-op. Logging must never throw into the UI.
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -20,10 +20,7 @@ import {
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { COLOR_BY_FIELDS } from './constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
|
||||
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
|
||||
import TraceStoreSync from './stores/TraceStoreSync';
|
||||
import { useTraceStore } from './stores/traceStore';
|
||||
import { AGGREGATIONS } from './utils/aggregations';
|
||||
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
|
||||
@@ -59,14 +56,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
// Tracks which traceId the load event already fired for, so navigating
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef('');
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||
|
||||
const handleSpanDetailsClose = useCallback((): void => {
|
||||
urlQuery.delete('spanId');
|
||||
safeNavigate({ search: urlQuery.toString() });
|
||||
@@ -165,46 +154,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!traceId ||
|
||||
dataLoadedFiredForRef.current === traceId ||
|
||||
!userPrefsReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const payload = traceData?.payload;
|
||||
if (!payload?.spans?.length) {
|
||||
return;
|
||||
}
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||
[TraceDetailEventKeys.EntryPreferOldView]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW) === 'true',
|
||||
});
|
||||
}, [
|
||||
traceId,
|
||||
userPrefsReady,
|
||||
traceData,
|
||||
totalSpansCount,
|
||||
colorByField,
|
||||
previewFieldsCount,
|
||||
logTraceEvent,
|
||||
]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
@@ -284,12 +233,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
const handleCollapseChange = (key: string): void => {
|
||||
logTraceEvent(
|
||||
key === 'flame'
|
||||
? TraceDetailEvents.FlameGraphToggled
|
||||
: TraceDetailEvents.WaterfallToggled,
|
||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||
);
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ChevronDown, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
import { PrettyView } from 'periscope/components/PrettyView';
|
||||
import { PrettyViewProps } from 'periscope/components/PrettyView';
|
||||
@@ -13,8 +12,6 @@ import './DataViewer.styles.scss';
|
||||
|
||||
type ViewMode = 'pretty' | 'json';
|
||||
|
||||
const VIEW_MODE_CHANGED_EVENT = 'Data Viewer: View mode changed';
|
||||
|
||||
const VIEW_MODE_OPTIONS: { label: string; value: ViewMode }[] = [
|
||||
{ label: 'Pretty', value: 'pretty' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
@@ -37,20 +34,6 @@ function DataViewer({
|
||||
|
||||
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
||||
|
||||
const handleViewModeChange = (value: string): void => {
|
||||
const next = value as ViewMode;
|
||||
setViewMode(next);
|
||||
try {
|
||||
logEvent(VIEW_MODE_CHANGED_EVENT, {
|
||||
viewMode: next,
|
||||
path: window.location.pathname,
|
||||
drawerKey,
|
||||
});
|
||||
} catch {
|
||||
// No op
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (): void => {
|
||||
const text = JSON.stringify(data, null, 2);
|
||||
setCopy(text);
|
||||
@@ -73,7 +56,7 @@ function DataViewer({
|
||||
{
|
||||
type: 'radio-group',
|
||||
value: viewMode,
|
||||
onChange: handleViewModeChange,
|
||||
onChange: (value): void => setViewMode(value as ViewMode),
|
||||
children: VIEW_MODE_OPTIONS.map((opt) => ({
|
||||
type: 'radio',
|
||||
key: opt.value,
|
||||
|
||||
@@ -74,7 +74,7 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary
|
||||
events, status_message, status_code_string, kind_string, parent_span_id,
|
||||
flags, is_remote, trace_state, status_code,
|
||||
db_name, db_operation, http_method, http_url, http_host,
|
||||
external_http_method, external_http_url, response_status_code, links as references
|
||||
external_http_method, external_http_url, response_status_code
|
||||
FROM %s.%s
|
||||
WHERE trace_id=? AND ts_bucket_start>=? AND ts_bucket_start<=?
|
||||
ORDER BY timestamp ASC, name ASC`,
|
||||
@@ -130,7 +130,7 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
|
||||
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
|
||||
"flags", "is_remote", "trace_state", "status_code",
|
||||
"db_name", "db_operation", "http_method", "http_url", "http_host",
|
||||
"external_http_method", "external_http_url", "response_status_code", "links as references",
|
||||
"external_http_method", "external_http_url", "response_status_code",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
ids := make([]any, len(spanIDs))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/contextlinks"
|
||||
@@ -85,19 +86,35 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*q
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
|
||||
// logSamples* bound the recent log lines we attach to a firing log-based alert.
|
||||
// They are kept small to bound the notification payload size.
|
||||
const (
|
||||
// logSamplesMaxCount is the number of most-recent matching log records sampled.
|
||||
logSamplesMaxCount = 5
|
||||
// logSampleBodyMaxLen truncates each sampled body (in runes) so a single large
|
||||
// record cannot blow up the annotation/notification.
|
||||
logSampleBodyMaxLen = 512
|
||||
)
|
||||
|
||||
// logsQueryParams extracts, for a log-based alert, the evaluation window and the
|
||||
// per-group where clause: the rule's filter combined with the breaching group's
|
||||
// label values (lbls). The same where clause backs both the related-logs link and
|
||||
// the sample-logs query, so they always refer to the same set of logs. ok is false
|
||||
// when the rule is not a single log builder query (e.g. a formula or non-logs
|
||||
// signal), in which case there is nothing to link to or sample.
|
||||
func (r *ThresholdRule) logsQueryParams(ctx context.Context, ts time.Time, lbls ruletypes.Labels) (start, end time.Time, whereClause string, ok bool) {
|
||||
selectedQuery := r.SelectedQuery(ctx)
|
||||
|
||||
qr, err := r.prepareQueryRange(ctx, ts)
|
||||
if err != nil {
|
||||
return nil
|
||||
return time.Time{}, time.Time{}, "", false
|
||||
}
|
||||
start := time.UnixMilli(int64(qr.Start))
|
||||
end := time.UnixMilli(int64(qr.End))
|
||||
start = time.UnixMilli(int64(qr.Start))
|
||||
end = time.UnixMilli(int64(qr.End))
|
||||
|
||||
// TODO(srikanthccv): handle formula queries
|
||||
if selectedQuery < "A" || selectedQuery > "Z" {
|
||||
return nil
|
||||
return time.Time{}, time.Time{}, "", false
|
||||
}
|
||||
|
||||
var q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
@@ -112,7 +129,7 @@ func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time,
|
||||
}
|
||||
|
||||
if q.Signal != telemetrytypes.SignalLogs {
|
||||
return nil
|
||||
return time.Time{}, time.Time{}, "", false
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
@@ -120,11 +137,153 @@ func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time,
|
||||
filterExpr = q.Filter.Expression
|
||||
}
|
||||
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, q.GroupBy)
|
||||
whereClause = contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, q.GroupBy)
|
||||
return start, end, whereClause, true
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
|
||||
start, end, whereClause, ok := r.logsQueryParams(ctx, ts, lbls)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause)
|
||||
}
|
||||
|
||||
// fetchLogSamples returns up to logSamplesMaxCount of the most-recent log records
|
||||
// matching the alert's filter for the breaching group (lbls), newest first. It
|
||||
// reuses the same where clause as the related-logs link, so the samples are exactly
|
||||
// the logs that link points to.
|
||||
//
|
||||
// Sampling is best-effort enrichment: any failure is logged and yields no samples
|
||||
// rather than failing the rule evaluation.
|
||||
func (r *ThresholdRule) fetchLogSamples(ctx context.Context, ts time.Time, lbls ruletypes.Labels) []*qbtypes.RawRow {
|
||||
start, end, whereClause, ok := r.logsQueryParams(ctx, ts, lbls)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(start.UnixMilli()),
|
||||
End: uint64(end.UnixMilli()),
|
||||
RequestType: qbtypes.RequestTypeRaw,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Name: "log_samples",
|
||||
Filter: &qbtypes.Filter{Expression: whereClause},
|
||||
Limit: logSamplesMaxCount,
|
||||
// timestamp,id DESC => most recent first. Both keys with an
|
||||
// identical direction are also what enables the window-list
|
||||
// fast path for raw log queries.
|
||||
Order: []qbtypes.OrderBy{
|
||||
{
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "timestamp", Materialized: true}},
|
||||
},
|
||||
{
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "id", Materialized: true}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
NoCache: true,
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "rules",
|
||||
instrumentationtypes.CodeFunctionName: "fetchLogSamples",
|
||||
})
|
||||
|
||||
resp, err := r.querier.QueryRange(ctx, r.orgID, req)
|
||||
if err != nil {
|
||||
r.logger.WarnContext(ctx, "failed to fetch log samples for alert annotation", errors.Attr(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range resp.Data.Results {
|
||||
if raw, ok := item.(*qbtypes.RawData); ok {
|
||||
return raw.Rows
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatLogSamples renders sampled log records as a compact, monospaced markdown
|
||||
// block: one line per record as "[RFC3339] SEVERITY body". Records without a body
|
||||
// are skipped (mirroring Datadog), each body is collapsed to a single line and
|
||||
// truncated to logSampleBodyMaxLen. Returns "" when there is nothing to show.
|
||||
func formatLogSamples(rows []*qbtypes.RawRow) string {
|
||||
lines := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(rawRowStringField(row, "body"))
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
body = truncateRunes(logSampleSingleLine(body), logSampleBodyMaxLen)
|
||||
|
||||
var sb strings.Builder
|
||||
if !row.Timestamp.IsZero() {
|
||||
sb.WriteString("[")
|
||||
sb.WriteString(row.Timestamp.UTC().Format(time.RFC3339))
|
||||
sb.WriteString("] ")
|
||||
}
|
||||
if sev := strings.TrimSpace(rawRowStringField(row, "severity_text")); sev != "" {
|
||||
sb.WriteString(sev)
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
sb.WriteString(body)
|
||||
lines = append(lines, sb.String())
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "```\n" + strings.Join(lines, "\n") + "\n```"
|
||||
}
|
||||
|
||||
// rawRowStringField returns the named field from a raw row as a string, or "" if it
|
||||
// is absent or not a string.
|
||||
func rawRowStringField(row *qbtypes.RawRow, key string) string {
|
||||
if row == nil || row.Data == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := row.Data[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// logSampleSingleLine collapses newlines so a multi-line log body renders as one
|
||||
// line within the samples block.
|
||||
func logSampleSingleLine(s string) string {
|
||||
replacer := strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ")
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
|
||||
// truncateRunes shortens s to at most max runes, appending an ellipsis when trimmed.
|
||||
func truncateRunes(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
return string(runes[:max]) + "…"
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
|
||||
selectedQuery := r.SelectedQuery(ctx)
|
||||
|
||||
@@ -352,6 +511,14 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("annotation.link", link))
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogs, Value: link})
|
||||
}
|
||||
// Attach a few recent matching log lines so responders see what fired
|
||||
// the alert without leaving the notification. Skipped for no-data
|
||||
// alerts, which by definition have no matching logs.
|
||||
if !smpl.IsMissing {
|
||||
if samples := formatLogSamples(r.fetchLogSamples(ctx, ts, smpl.Metric)); samples != "" {
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogsSamples, Value: samples})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lbs := lb.Labels()
|
||||
|
||||
72
pkg/query-service/rules/threshold_rule_log_samples_test.go
Normal file
72
pkg/query-service/rules/threshold_rule_log_samples_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFormatLogSamples(t *testing.T) {
|
||||
ts := time.Date(2026, time.June, 1, 12, 0, 3, 0, time.UTC)
|
||||
|
||||
t.Run("nil and empty yield empty string", func(t *testing.T) {
|
||||
assert.Equal(t, "", formatLogSamples(nil))
|
||||
assert.Equal(t, "", formatLogSamples([]*qbtypes.RawRow{}))
|
||||
})
|
||||
|
||||
t.Run("skips nil rows and rows without a usable body", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
nil,
|
||||
{Timestamp: ts, Data: map[string]any{"body": ""}},
|
||||
{Timestamp: ts, Data: map[string]any{"body": " "}},
|
||||
{Timestamp: ts, Data: map[string]any{"severity_text": "ERROR"}}, // no body key
|
||||
{Timestamp: ts, Data: map[string]any{"body": 42}}, // body not a string
|
||||
}
|
||||
assert.Equal(t, "", formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("renders timestamp, severity and body inside a code block", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Timestamp: ts, Data: map[string]any{"severity_text": "ERROR", "body": "payment failed"}},
|
||||
}
|
||||
want := "```\n[2026-06-01T12:00:03Z] ERROR payment failed\n```"
|
||||
assert.Equal(t, want, formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("omits severity when absent and collapses a multi-line body", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Timestamp: ts, Data: map[string]any{"body": "line1\nline2\r\nline3"}},
|
||||
}
|
||||
want := "```\n[2026-06-01T12:00:03Z] line1 line2 line3\n```"
|
||||
assert.Equal(t, want, formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("omits the timestamp prefix when zero", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Data: map[string]any{"body": "no ts"}},
|
||||
}
|
||||
assert.Equal(t, "```\nno ts\n```", formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("renders one line per record and preserves input order", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Timestamp: ts, Data: map[string]any{"body": "first"}},
|
||||
{Timestamp: ts.Add(-time.Second), Data: map[string]any{"body": "second"}},
|
||||
}
|
||||
want := "```\n[2026-06-01T12:00:03Z] first\n[2026-06-01T12:00:02Z] second\n```"
|
||||
assert.Equal(t, want, formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("truncates a long body to logSampleBodyMaxLen runes plus ellipsis", func(t *testing.T) {
|
||||
long := strings.Repeat("a", logSampleBodyMaxLen+50)
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Timestamp: ts, Data: map[string]any{"body": long}},
|
||||
}
|
||||
out := formatLogSamples(rows)
|
||||
assert.Contains(t, out, strings.Repeat("a", logSampleBodyMaxLen)+"…")
|
||||
assert.NotContains(t, out, strings.Repeat("a", logSampleBodyMaxLen+1))
|
||||
})
|
||||
}
|
||||
@@ -124,10 +124,8 @@ func (b *traceQueryStatementBuilder) Build(
|
||||
-------------------------------- End of tech debt ----------------------------
|
||||
*/
|
||||
|
||||
for _, action := range adjustTraceKeys(keys, &query, requestType) {
|
||||
// TODO: change to debug level once we are confident about the behavior
|
||||
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
query = b.adjustKeys(ctx, keys, query, requestType)
|
||||
|
||||
// Create SQL builder
|
||||
q := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
@@ -195,30 +193,24 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
|
||||
return keySelectors
|
||||
}
|
||||
|
||||
// mergeDeprecatedTraceKeys prepends deprecated intrinsic/calculated trace field
|
||||
// definitions to the keys map. We do this during statement building, not at
|
||||
// metadata fetch time, because:
|
||||
// 1. Filter expressions that reference deprecated columns must continue to
|
||||
// resolve — otherwise they fail with "key not found".
|
||||
// 2. Doing it at metadata fetch time would also surface deprecated keys in
|
||||
// autocomplete suggestions, which we don't want.
|
||||
// 3. We prepend (not append) so the intrinsic/calculated entry wins ordering
|
||||
// in the multi_if SQL expression.
|
||||
func mergeDeprecatedTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey) {
|
||||
func (b *traceQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[string][]*telemetrytypes.TelemetryFieldKey, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], requestType qbtypes.RequestType) qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation] {
|
||||
|
||||
// add deprecated fields only during statement building
|
||||
// why?
|
||||
// 1. to not fail filter expression that use deprecated cols
|
||||
// 2. this could have been moved to metadata fetching itself, however, that
|
||||
// would mean, they also show up in suggestions we we don't want to do
|
||||
// 3. reason for not doing a simple append is to keep intrinsic/calculated field first so that it gets
|
||||
// priority in multi_if sql expression
|
||||
for fieldKeyName, fieldKey := range IntrinsicFieldsDeprecated {
|
||||
keys[fieldKeyName] = append([]*telemetrytypes.TelemetryFieldKey{&fieldKey}, keys[fieldKeyName]...)
|
||||
}
|
||||
for fieldKeyName, fieldKey := range CalculatedFieldsDeprecated {
|
||||
keys[fieldKeyName] = append([]*telemetrytypes.TelemetryFieldKey{&fieldKey}, keys[fieldKeyName]...)
|
||||
}
|
||||
}
|
||||
|
||||
func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query *qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], requestType qbtypes.RequestType) []string {
|
||||
|
||||
mergeDeprecatedTraceKeys(keys)
|
||||
|
||||
// Adjust keys for alias expressions in aggregations
|
||||
actions := querybuilder.AdjustKeysForAliasExpressions(query, requestType)
|
||||
actions := querybuilder.AdjustKeysForAliasExpressions(&query, requestType)
|
||||
|
||||
/*
|
||||
Check if user is using multiple contexts or data types for same field name
|
||||
@@ -236,7 +228,7 @@ func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query
|
||||
and make it just http.status_code and remove the duplicate entry.
|
||||
*/
|
||||
|
||||
actions = append(actions, querybuilder.AdjustDuplicateKeys(query)...)
|
||||
actions = append(actions, querybuilder.AdjustDuplicateKeys(&query)...)
|
||||
|
||||
/*
|
||||
Now adjust each key to have correct context and data type
|
||||
@@ -244,20 +236,24 @@ func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query
|
||||
Reason for doing this is to not create an unexpected behavior for users
|
||||
*/
|
||||
for idx := range query.SelectFields {
|
||||
actions = append(actions, adjustTraceKey(&query.SelectFields[idx], keys)...)
|
||||
actions = append(actions, b.adjustKey(&query.SelectFields[idx], keys)...)
|
||||
}
|
||||
for idx := range query.GroupBy {
|
||||
actions = append(actions, adjustTraceKey(&query.GroupBy[idx].TelemetryFieldKey, keys)...)
|
||||
actions = append(actions, b.adjustKey(&query.GroupBy[idx].TelemetryFieldKey, keys)...)
|
||||
}
|
||||
for idx := range query.Order {
|
||||
actions = append(actions, adjustTraceKey(&query.Order[idx].Key.TelemetryFieldKey, keys)...)
|
||||
actions = append(actions, b.adjustKey(&query.Order[idx].Key.TelemetryFieldKey, keys)...)
|
||||
}
|
||||
|
||||
return actions
|
||||
for _, action := range actions {
|
||||
// TODO: change to debug level once we are confident about the behavior
|
||||
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// adjustTraceKey resolves a single TelemetryFieldKey against the keys map.
|
||||
func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
|
||||
func (b *traceQueryStatementBuilder) adjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
|
||||
|
||||
// for recording actions taken
|
||||
actions := []string{}
|
||||
|
||||
@@ -1125,13 +1125,28 @@ func TestAdjustKey(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
// Create a copy of the input key to avoid modifying the original
|
||||
key := c.inputKey
|
||||
|
||||
// Call adjustKey
|
||||
adjustTraceKey(&key, c.keysMap)
|
||||
statementBuilder.adjustKey(&key, c.keysMap)
|
||||
|
||||
// Verify the key was adjusted as expected
|
||||
require.Equal(t, c.expectedKey.Name, key.Name, "key name should match")
|
||||
@@ -1384,6 +1399,21 @@ func TestAdjustKeys(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
// Create a deep copy of the keys map to avoid modifying the original
|
||||
@@ -1394,7 +1424,7 @@ func TestAdjustKeys(t *testing.T) {
|
||||
}
|
||||
|
||||
// Call adjustKeys
|
||||
adjustTraceKeys(keysMapCopy, &c.query, qbtypes.RequestTypeScalar)
|
||||
c.query = statementBuilder.adjustKeys(context.Background(), keysMapCopy, c.query, qbtypes.RequestTypeScalar)
|
||||
|
||||
// Verify select fields were adjusted
|
||||
if c.expectedSelectFields != nil {
|
||||
|
||||
@@ -216,13 +216,6 @@ func (b *traceOperatorCTEBuilder) buildQueryCTE(ctx context.Context, queryName s
|
||||
}
|
||||
b.stmtBuilder.logger.DebugContext(ctx, "Retrieved keys for query", slog.String("query_name", queryName), slog.Int("keys_count", len(keys)))
|
||||
|
||||
// The CTE only selects spans matching the filter. Aggregations, group by
|
||||
// and order by run later in buildFinalQuery, so RequestTypeRaw is fine here.
|
||||
for _, action := range adjustTraceKeys(keys, query, qbtypes.RequestTypeRaw) {
|
||||
// TODO: change to debug level once we are confident about the behavior
|
||||
b.stmtBuilder.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
|
||||
// Build resource filter CTE for this specific query
|
||||
resourceFilterCTEName := fmt.Sprintf("__resource_filter_%s", cteName)
|
||||
resourceStmt, err := b.buildResourceFilterCTE(ctx, *query)
|
||||
@@ -424,28 +417,21 @@ func (b *traceOperatorCTEBuilder) buildNotCTE(leftCTE, rightCTE string) (string,
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFromCTE string, requestType qbtypes.RequestType) (*qbtypes.Statement, error) {
|
||||
keySelectors := b.getKeySelectors()
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.adjustOperatorKeys(ctx, keys, requestType)
|
||||
|
||||
switch requestType {
|
||||
case qbtypes.RequestTypeRaw:
|
||||
return b.buildListQuery(ctx, selectFromCTE, keys)
|
||||
return b.buildListQuery(ctx, selectFromCTE)
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
return b.buildTimeSeriesQuery(ctx, selectFromCTE, keys)
|
||||
return b.buildTimeSeriesQuery(ctx, selectFromCTE)
|
||||
case qbtypes.RequestTypeTrace:
|
||||
return b.buildTraceQuery(ctx, selectFromCTE, keys)
|
||||
return b.buildTraceQuery(ctx, selectFromCTE)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
return b.buildScalarQuery(ctx, selectFromCTE, keys)
|
||||
return b.buildScalarQuery(ctx, selectFromCTE)
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type: %s", requestType)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
// Select core fields
|
||||
@@ -467,6 +453,22 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
|
||||
"parent_span_id": true,
|
||||
}
|
||||
|
||||
// Get keys for selectFields
|
||||
keySelectors := b.getKeySelectors()
|
||||
for _, field := range b.operator.SelectFields {
|
||||
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: field.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: field.FieldContext,
|
||||
FieldDataType: field.FieldDataType,
|
||||
})
|
||||
}
|
||||
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add selectFields using ColumnExpressionFor since we now have all base table columns
|
||||
for _, field := range b.operator.SelectFields {
|
||||
if selectedFields[field.Name] {
|
||||
@@ -516,44 +518,6 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
|
||||
}, nil
|
||||
}
|
||||
|
||||
// adjustOperatorKeys runs the same key adjustments as adjustTraceKeys, but on
|
||||
// the operator's own fields. The operator has a different struct shape than
|
||||
// QueryBuilderQuery, so we copy the relevant fields into a temp query, run
|
||||
// the shared helpers, and copy the results back.
|
||||
func (b *traceOperatorCTEBuilder) adjustOperatorKeys(ctx context.Context, keys map[string][]*telemetrytypes.TelemetryFieldKey, requestType qbtypes.RequestType) {
|
||||
mergeDeprecatedTraceKeys(keys)
|
||||
|
||||
tmp := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Aggregations: b.operator.Aggregations,
|
||||
SelectFields: b.operator.SelectFields,
|
||||
GroupBy: b.operator.GroupBy,
|
||||
Order: b.operator.Order,
|
||||
}
|
||||
|
||||
actions := querybuilder.AdjustKeysForAliasExpressions(&tmp, requestType)
|
||||
actions = append(actions, querybuilder.AdjustDuplicateKeys(&tmp)...)
|
||||
|
||||
for idx := range tmp.SelectFields {
|
||||
actions = append(actions, adjustTraceKey(&tmp.SelectFields[idx], keys)...)
|
||||
}
|
||||
for idx := range tmp.GroupBy {
|
||||
actions = append(actions, adjustTraceKey(&tmp.GroupBy[idx].TelemetryFieldKey, keys)...)
|
||||
}
|
||||
for idx := range tmp.Order {
|
||||
actions = append(actions, adjustTraceKey(&tmp.Order[idx].Key.TelemetryFieldKey, keys)...)
|
||||
}
|
||||
|
||||
// Copy back the slices the helpers can rewrite.
|
||||
b.operator.Aggregations = tmp.Aggregations
|
||||
b.operator.SelectFields = tmp.SelectFields
|
||||
b.operator.GroupBy = tmp.GroupBy
|
||||
b.operator.Order = tmp.Order
|
||||
|
||||
for _, action := range actions {
|
||||
b.stmtBuilder.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySelector {
|
||||
var keySelectors []*telemetrytypes.FieldKeySelector
|
||||
|
||||
@@ -581,15 +545,6 @@ func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySe
|
||||
})
|
||||
}
|
||||
|
||||
for _, sf := range b.operator.SelectFields {
|
||||
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: sf.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: sf.FieldContext,
|
||||
FieldDataType: sf.FieldDataType,
|
||||
})
|
||||
}
|
||||
|
||||
for i := range keySelectors {
|
||||
keySelectors[i].Signal = telemetrytypes.SignalTraces
|
||||
}
|
||||
@@ -597,7 +552,7 @@ func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySe
|
||||
return keySelectors
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
sb.Select(fmt.Sprintf(
|
||||
@@ -605,6 +560,12 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
|
||||
int64(b.operator.StepInterval.Seconds()),
|
||||
))
|
||||
|
||||
keySelectors := b.getKeySelectors()
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allGroupByArgs []any
|
||||
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
@@ -683,7 +644,8 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
|
||||
combinedArgs := append(allGroupByArgs, allAggChArgs...)
|
||||
|
||||
// Add HAVING clause if specified
|
||||
if err := b.addHavingClause(sb); err != nil {
|
||||
err = b.addHavingClause(sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -710,11 +672,17 @@ func (b *traceOperatorCTEBuilder) buildTraceSummaryCTE(selectFromCTE string) {
|
||||
b.addCTE("trace_summary", sql, args, []string{"all_spans", selectFromCTE})
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
|
||||
b.buildTraceSummaryCTE(selectFromCTE)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
keySelectors := b.getKeySelectors()
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allGroupByArgs []any
|
||||
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
@@ -796,7 +764,8 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
sb.GroupBy(groupByKeys...)
|
||||
}
|
||||
|
||||
if err := b.addHavingClause(sb); err != nil {
|
||||
err = b.addHavingClause(sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -852,9 +821,15 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
keySelectors := b.getKeySelectors()
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allGroupByArgs []any
|
||||
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
@@ -936,7 +911,8 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
|
||||
combinedArgs := append(allGroupByArgs, allAggChArgs...)
|
||||
|
||||
// Add HAVING clause if specified
|
||||
if err := b.addHavingClause(sb); err != nil {
|
||||
err = b.addHavingClause(sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -14,24 +14,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestTraceOperatorStatementBuilder(t *testing.T) *traceOperatorStatementBuilder {
|
||||
t.Helper()
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
traceStmtBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore, fm, cb, aggExprRewriter, nil, fl,
|
||||
)
|
||||
return NewTraceOperatorStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore, fm, cb, traceStmtBuilder, aggExprRewriter, fl,
|
||||
)
|
||||
}
|
||||
|
||||
func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -481,7 +463,32 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
statementBuilder := newTestTraceOperatorStatementBuilder(t)
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
|
||||
traceStmtBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
)
|
||||
|
||||
statementBuilder := NewTraceOperatorStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
traceStmtBuilder,
|
||||
aggExprRewriter,
|
||||
fl,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -572,7 +579,32 @@ func TestTraceOperatorStatementBuilderErrors(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
statementBuilder := newTestTraceOperatorStatementBuilder(t)
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
|
||||
traceStmtBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
)
|
||||
|
||||
statementBuilder := NewTraceOperatorStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
traceStmtBuilder,
|
||||
aggExprRewriter,
|
||||
fl,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -594,142 +626,3 @@ func TestTraceOperatorStatementBuilderErrors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceOperatorStatementBuilderAdjustsKeys(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
operator qbtypes.QueryBuilderTraceOperator
|
||||
builderFilter string
|
||||
wantSQL string
|
||||
wantArgs []any
|
||||
}{
|
||||
{
|
||||
name: "deprecated duration filter in referenced builder query",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
operator: qbtypes.QueryBuilderTraceOperator{
|
||||
Expression: "A",
|
||||
Limit: 10,
|
||||
},
|
||||
builderFilter: "durationNano = '3s'",
|
||||
wantSQL: "duration_nano = ?",
|
||||
wantArgs: []any{int64(3000000000)},
|
||||
},
|
||||
{
|
||||
name: "context-prefixed aggregation alias in order by",
|
||||
requestType: qbtypes.RequestTypeScalar,
|
||||
operator: qbtypes.QueryBuilderTraceOperator{
|
||||
Expression: "A",
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
Alias: "span.count_",
|
||||
},
|
||||
},
|
||||
Order: []qbtypes.OrderBy{
|
||||
{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "count_",
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantSQL: "ORDER BY __result_0 desc",
|
||||
},
|
||||
}
|
||||
|
||||
statementBuilder := newTestTraceOperatorStatementBuilder(t)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := c.operator.ParseExpression()
|
||||
require.NoError(t, err)
|
||||
|
||||
filter := c.builderFilter
|
||||
if filter == "" {
|
||||
filter = "service.name = 'frontend'"
|
||||
}
|
||||
|
||||
q, err := statementBuilder.Build(
|
||||
context.Background(),
|
||||
1747947419000,
|
||||
1747983448000,
|
||||
c.requestType,
|
||||
c.operator,
|
||||
&qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: filter},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, q.Query, c.wantSQL)
|
||||
for _, arg := range c.wantArgs {
|
||||
require.Contains(t, q.Args, arg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTraceOperatorStatementBuilderDeduplicatesKeys checks that a trace
|
||||
// operator with the same field name listed twice in GroupBy (once with a
|
||||
// context, once without) ends up with a single column in the outer SELECT
|
||||
// and a single entry in GROUP BY.
|
||||
func TestTraceOperatorStatementBuilderDeduplicatesKeys(t *testing.T) {
|
||||
statementBuilder := newTestTraceOperatorStatementBuilder(t)
|
||||
|
||||
operator := qbtypes.QueryBuilderTraceOperator{
|
||||
Expression: "A",
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "count()"},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "http.method",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
}},
|
||||
// Same name, no context — should be merged with the entry above.
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "http.method",
|
||||
}},
|
||||
},
|
||||
}
|
||||
require.NoError(t, operator.ParseExpression())
|
||||
|
||||
q, err := statementBuilder.Build(
|
||||
context.Background(),
|
||||
1747947419000,
|
||||
1747983448000,
|
||||
qbtypes.RequestTypeScalar,
|
||||
operator,
|
||||
&qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'frontend'"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Contains(t, q.Query,
|
||||
"SELECT toString(multiIf(mapContains(attributes_string, 'http.method') = ?, attributes_string['http.method'], NULL)) AS `http.method`, count() AS __result_0 FROM A GROUP BY `http.method` ORDER BY __result_0 DESC")
|
||||
}
|
||||
|
||||
@@ -30,12 +30,13 @@ const (
|
||||
// {{ .Annotations.value }}, {{ .Annotations.threshold.value }}, etc. in
|
||||
// their channel templates.
|
||||
const (
|
||||
AnnotationTitleTemplate = "_title_template"
|
||||
AnnotationBodyTemplate = "_body_template"
|
||||
AnnotationRelatedLogs = "related_logs"
|
||||
AnnotationRelatedTraces = "related_traces"
|
||||
AnnotationValue = "value"
|
||||
AnnotationThresholdValue = "threshold.value"
|
||||
AnnotationCompareOp = "compare_op"
|
||||
AnnotationMatchType = "match_type"
|
||||
AnnotationTitleTemplate = "_title_template"
|
||||
AnnotationBodyTemplate = "_body_template"
|
||||
AnnotationRelatedLogs = "related_logs"
|
||||
AnnotationRelatedLogsSamples = "related_logs_samples"
|
||||
AnnotationRelatedTraces = "related_traces"
|
||||
AnnotationValue = "value"
|
||||
AnnotationThresholdValue = "threshold.value"
|
||||
AnnotationCompareOp = "compare_op"
|
||||
AnnotationMatchType = "match_type"
|
||||
)
|
||||
|
||||
@@ -54,12 +54,6 @@ type Event struct {
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
type OtelSpanRef struct {
|
||||
TraceId string `json:"traceId,omitempty"`
|
||||
SpanId string `json:"spanId,omitempty"`
|
||||
RefType string `json:"refType,omitempty"`
|
||||
}
|
||||
|
||||
// WaterfallSpan represents the span in waterfall response,
|
||||
// this uses snake_case keys for response as a special case since these
|
||||
// keys can be directly used to query spans and client need to know the actual fields.
|
||||
@@ -80,7 +74,6 @@ type WaterfallSpan struct {
|
||||
TimeUnix uint64 `json:"time_unix"`
|
||||
TraceID string `json:"trace_id"`
|
||||
TraceState string `json:"trace_state"`
|
||||
References []OtelSpanRef `json:"references" required:"true" nullable:"false"`
|
||||
|
||||
// Calculated fields https://signoz.io/docs/traces-management/guides/derived-fields-spans
|
||||
DBName string `json:"db_name,omitempty"`
|
||||
@@ -135,7 +128,6 @@ type StorableSpan struct {
|
||||
ExternalHTTPMethod string `ch:"external_http_method"`
|
||||
ExternalHTTPURL string `ch:"external_http_url"`
|
||||
ResponseStatusCode string `ch:"response_status_code"`
|
||||
References string `ch:"references"`
|
||||
}
|
||||
|
||||
// MinimalSpan with only the fields needed to build the parent-child tree.
|
||||
@@ -293,14 +285,6 @@ func (item *StorableSpan) UnmarshalledEvents() []Event {
|
||||
return events
|
||||
}
|
||||
|
||||
func (item *StorableSpan) UnmarshalledRefs() []OtelSpanRef {
|
||||
refs := []OtelSpanRef{}
|
||||
if err := json.Unmarshal([]byte(item.References), &refs); err != nil {
|
||||
return nil // skip malformed values
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func (item *StorableSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
|
||||
resources := make(map[string]string)
|
||||
maps.Copy(resources, item.ResourcesString)
|
||||
@@ -334,7 +318,6 @@ func (item *StorableSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
|
||||
Children: make([]*WaterfallSpan, 0),
|
||||
TimeUnix: uint64(item.StartTime.UnixNano()),
|
||||
ServiceName: item.ServiceName,
|
||||
References: item.UnmarshalledRefs(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,16 +54,16 @@ func newConfig() factory.Config {
|
||||
Directory: "/etc/signoz/web",
|
||||
Settings: SettingsConfig{
|
||||
Posthog: PosthogConfig{
|
||||
Enabled: false,
|
||||
Enabled: true,
|
||||
},
|
||||
Appcues: AppcuesConfig{
|
||||
Enabled: false,
|
||||
Enabled: true,
|
||||
},
|
||||
Sentry: SentryConfig{
|
||||
Enabled: false,
|
||||
Enabled: true,
|
||||
},
|
||||
Pylon: PylonConfig{
|
||||
Enabled: false,
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
51
tests/fixtures/querier.py
vendored
51
tests/fixtures/querier.py
vendored
@@ -459,57 +459,6 @@ def find_named_result(
|
||||
)
|
||||
|
||||
|
||||
def assert_scalar_value(
|
||||
response: requests.Response,
|
||||
name: str,
|
||||
expected: Any,
|
||||
*,
|
||||
row: int = 0,
|
||||
col: int = 0,
|
||||
) -> None:
|
||||
"""Assert that the named scalar result has `expected` at data[row][col]."""
|
||||
result = find_named_result(response.json()["data"]["data"]["results"], name)
|
||||
assert result is not None, f"no result for query {name}"
|
||||
assert result["data"][row][col] == expected, f"expected {expected} at [{row}][{col}], got {result['data'][row][col]}"
|
||||
|
||||
|
||||
def assert_grouped_scalar(
|
||||
response: requests.Response,
|
||||
name: str,
|
||||
*,
|
||||
expected_groups: int,
|
||||
expected_columns: int,
|
||||
last_col_value: Any | None = None,
|
||||
) -> None:
|
||||
"""Assert grouped scalar result has the expected column count and group count.
|
||||
If `last_col_value` is set and there is exactly one group, also assert the
|
||||
last column of that single row equals it (a common aggregation-value check)."""
|
||||
result = find_named_result(response.json()["data"]["data"]["results"], name)
|
||||
assert result is not None, f"no result for query {name}"
|
||||
columns = result["columns"]
|
||||
rows = result["data"]
|
||||
assert len(columns) == expected_columns, f"expected {expected_columns} columns, got {len(columns)}: {columns}"
|
||||
assert len(rows) == expected_groups, f"expected {expected_groups} groups, got {len(rows)}: {rows}"
|
||||
if last_col_value is not None and expected_groups == 1:
|
||||
assert rows[0][-1] == last_col_value, f"expected last col {last_col_value}, got row {rows[0]}"
|
||||
|
||||
|
||||
def assert_raw_row_subset(
|
||||
response: requests.Response,
|
||||
name: str,
|
||||
expected: dict[str, Any],
|
||||
*,
|
||||
row: int = 0,
|
||||
) -> None:
|
||||
"""Assert that the named raw result's rows[row]['data'] is a superset of `expected`."""
|
||||
result = find_named_result(response.json()["data"]["data"]["results"], name)
|
||||
assert result is not None, f"no result for query {name}"
|
||||
rows = result["rows"]
|
||||
assert rows is not None, f"no rows for query {name}"
|
||||
data = rows[row]["data"]
|
||||
assert expected.items() <= data.items(), f"expected subset {expected}, got data {data}"
|
||||
|
||||
|
||||
def build_scalar_query(
|
||||
name: str,
|
||||
signal: str,
|
||||
|
||||
@@ -25,22 +25,13 @@ returnSpansFrom="A"
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querier import (
|
||||
assert_grouped_scalar,
|
||||
assert_raw_row_subset,
|
||||
assert_scalar_value,
|
||||
format_timestamp,
|
||||
generate_traces_with_corrupt_metadata,
|
||||
get_rows,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.querier import get_rows
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
|
||||
@@ -443,173 +434,3 @@ def test_trace_operator(
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"HTTP {response.status_code}: {response.text}"
|
||||
assert case["validate"](response), f"validation failed: {response.json()}"
|
||||
|
||||
|
||||
def _expected_trace_subset(trace: Traces) -> dict[str, Any]:
|
||||
return {
|
||||
"duration_nano": trace.duration_nano,
|
||||
"name": trace.name,
|
||||
"parent_span_id": trace.parent_span_id,
|
||||
"span_id": trace.span_id,
|
||||
"timestamp": format_timestamp(trace.timestamp),
|
||||
"trace_id": trace.trace_id,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload_factory,request_type,assert_result",
|
||||
[
|
||||
# Case 1: CTE filter uses the deprecated intrinsic field `durationNano`.
|
||||
pytest.param(
|
||||
lambda traces: [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"filter": {"expression": 'durationNano = "3s"'},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "traces",
|
||||
"filter": {"expression": 'durationNano = "5s"'},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_trace_operator",
|
||||
"spec": {
|
||||
"name": "C",
|
||||
"expression": "A => B",
|
||||
"limit": 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
"raw",
|
||||
lambda response, traces: assert_raw_row_subset(response, "C", _expected_trace_subset(traces[0])),
|
||||
id="deprecated-intrinsic-filter",
|
||||
),
|
||||
# Case 2: CTE filter uses the deprecated calculated field `responseStatusCode`.
|
||||
pytest.param(
|
||||
lambda traces: [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"filter": {"expression": 'responseStatusCode = "200"'},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "traces",
|
||||
"filter": {"expression": 'durationNano = "5s"'},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_trace_operator",
|
||||
"spec": {
|
||||
"name": "C",
|
||||
"expression": "A => B",
|
||||
"limit": 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
"raw",
|
||||
lambda response, traces: assert_raw_row_subset(response, "C", _expected_trace_subset(traces[0])),
|
||||
id="deprecated-calculated-filter",
|
||||
),
|
||||
# Case 3: order by uses `count_` with fieldContext `span`, which has
|
||||
# to be rewritten to the aggregation alias `span.count_`.
|
||||
pytest.param(
|
||||
lambda traces: [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_trace_operator",
|
||||
"spec": {
|
||||
"name": "C",
|
||||
"expression": "A",
|
||||
"aggregations": [{"expression": "count()", "alias": "span.count_"}],
|
||||
"order": [{"key": {"name": "count_", "fieldContext": "span"}, "direction": "desc"}],
|
||||
},
|
||||
},
|
||||
],
|
||||
"scalar",
|
||||
lambda response, traces: assert_scalar_value(response, "C", len(traces)),
|
||||
id="context-prefixed-aggregation-alias-order",
|
||||
),
|
||||
# Case 4: group by lists `cloud.provider` twice (once with a resource
|
||||
# context, once without).
|
||||
pytest.param(
|
||||
lambda traces: [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"disabled": True,
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_trace_operator",
|
||||
"spec": {
|
||||
"name": "C",
|
||||
"expression": "A",
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
"groupBy": [
|
||||
{"name": "cloud.provider", "fieldContext": "resource"},
|
||||
{"name": "cloud.provider"},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"scalar",
|
||||
lambda response, traces: assert_grouped_scalar(response, "C", expected_groups=1, expected_columns=2, last_col_value=len(traces)),
|
||||
id="duplicate-group-by-deduplicated",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_trace_operator_with_adjusted_keys(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
payload_factory: Callable[[list[Traces]], list[dict[str, Any]]],
|
||||
request_type: str,
|
||||
assert_result: Callable[[requests.Response, list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Trace operators build a CTE per referenced builder query and an outer
|
||||
query on top. Both layers need the same key adjustment as regular trace
|
||||
queries, otherwise deprecated keys and context-prefixed aliases don't
|
||||
resolve.
|
||||
"""
|
||||
traces = generate_traces_with_corrupt_metadata()
|
||||
insert_traces(traces)
|
||||
payload = payload_factory(traces)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
|
||||
request_type=request_type,
|
||||
queries=payload,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert_result(response, traces)
|
||||
|
||||
Reference in New Issue
Block a user