mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-09 08:00:42 +01:00
Compare commits
7 Commits
issue_5601
...
fixes/dash
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fd24b7f15 | ||
|
|
8498bc86d0 | ||
|
|
d4bf7e7b9b | ||
|
|
68673f6717 | ||
|
|
ff3ec133b4 | ||
|
|
0ea5399c15 | ||
|
|
f7aa3cee9c |
@@ -65,7 +65,9 @@ function TagKeyValueInput({
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
// Plain Enter adds the tag; let Cmd/Ctrl+Enter pass through so a host form
|
||||
// (e.g. a modal) can submit on it.
|
||||
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
@@ -93,11 +95,17 @@ function TagKeyValueInput({
|
||||
};
|
||||
|
||||
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
// Plain Enter commits the edit; let Cmd/Ctrl+Enter pass through so a host
|
||||
// form (e.g. a modal) can submit on it.
|
||||
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
commitEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
// Contain Escape so it cancels the inline edit instead of bubbling up and
|
||||
// closing the host drawer/modal.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.wrapper :global(button[data-color]) {
|
||||
--checkbox-checked-background: var(--series-color);
|
||||
--checkbox-border-color: var(--series-color);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import { CheckBoxProps } from '../types';
|
||||
import styles from './CustomCheckBox.module.scss';
|
||||
|
||||
function CustomCheckBox({
|
||||
data,
|
||||
@@ -15,12 +16,11 @@ function CustomCheckBox({
|
||||
const isChecked = graphVisibilityState[index] || false;
|
||||
|
||||
const colorStyle = {
|
||||
'--checkbox-checked-background': color,
|
||||
'--checkbox-border-color': color,
|
||||
'--series-color': color,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<span style={colorStyle}>
|
||||
<span className={styles.wrapper} style={colorStyle}>
|
||||
<Checkbox
|
||||
onChange={(checked): void => checkBoxOnChangeHandler(checked, index)}
|
||||
value={isChecked}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -17,6 +22,13 @@ import dialsUrl from '@/assets/Icons/dials.svg';
|
||||
|
||||
import { getItemIcon } from '../constants';
|
||||
|
||||
// The five most-recent dashboards, normalised across the v1 and v2 list APIs.
|
||||
interface RecentDashboard {
|
||||
id: string;
|
||||
title: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export default function Dashboards({
|
||||
onUpdateChecklistDoneItem,
|
||||
loadingUserPreferences,
|
||||
@@ -26,33 +38,58 @@ export default function Dashboards({
|
||||
}): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { user } = useAppContext();
|
||||
const isDashboardV2 = useIsDashboardV2();
|
||||
|
||||
const [sortedDashboards, setSortedDashboards] = useState<Dashboard[]>([]);
|
||||
|
||||
// Fetch Dashboards
|
||||
// Fetch the recent dashboards from whichever API the `use_dashboard_v2` flag
|
||||
// selects; the inactive one stays disabled so it never fires.
|
||||
const {
|
||||
data: dashboardsList,
|
||||
isLoading: isDashboardListLoading,
|
||||
isError: isDashboardListError,
|
||||
} = useGetAllDashboard();
|
||||
data: v1List,
|
||||
isLoading: v1Loading,
|
||||
isError: v1Error,
|
||||
} = useGetAllDashboard({ enabled: !isDashboardV2 });
|
||||
|
||||
const {
|
||||
data: v2List,
|
||||
isLoading: v2Loading,
|
||||
isError: v2Error,
|
||||
} = useListDashboardsForUserV2(
|
||||
{
|
||||
sort: DashboardtypesListSortDTO.updated_at,
|
||||
order: DashboardtypesListOrderDTO.desc,
|
||||
limit: 5,
|
||||
offset: 0,
|
||||
},
|
||||
{ query: { enabled: isDashboardV2 } },
|
||||
);
|
||||
|
||||
const isDashboardListLoading = isDashboardV2 ? v2Loading : v1Loading;
|
||||
const isDashboardListError = isDashboardV2 ? v2Error : v1Error;
|
||||
|
||||
const sortedDashboards = useMemo<RecentDashboard[]>(() => {
|
||||
if (isDashboardV2) {
|
||||
return (v2List?.data?.dashboards ?? []).map((d) => ({
|
||||
id: d.id,
|
||||
title: d.spec?.display?.name ?? d.name,
|
||||
tags: (d.tags ?? []).map((t) => (t.value ? `${t.key}:${t.value}` : t.key)),
|
||||
}));
|
||||
}
|
||||
return [...(v1List?.data ?? [])]
|
||||
.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
)
|
||||
.slice(0, 5)
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.data.title,
|
||||
tags: d.data.tags ?? [],
|
||||
}));
|
||||
}, [isDashboardV2, v1List, v2List]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardsList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedDashboards = dashboardsList.data.sort((a, b) => {
|
||||
const aUpdateAt = new Date(a.updatedAt).getTime();
|
||||
const bUpdateAt = new Date(b.updatedAt).getTime();
|
||||
return bUpdateAt - aUpdateAt;
|
||||
});
|
||||
|
||||
if (sortedDashboards.length > 0 && !loadingUserPreferences) {
|
||||
onUpdateChecklistDoneItem('SETUP_DASHBOARDS');
|
||||
}
|
||||
|
||||
setSortedDashboards(sortedDashboards.slice(0, 5));
|
||||
}, [dashboardsList, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
}, [sortedDashboards, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
|
||||
const emptyStateCard = (): JSX.Element => (
|
||||
<div className="empty-state-container">
|
||||
@@ -113,7 +150,7 @@ export default function Dashboards({
|
||||
event.stopPropagation();
|
||||
logEvent('Homepage: Dashboard clicked', {
|
||||
dashboardId: dashboard.id,
|
||||
dashboardName: dashboard.data.title,
|
||||
dashboardName: dashboard.title,
|
||||
});
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
openInNewTab(getLink());
|
||||
@@ -143,12 +180,12 @@ export default function Dashboards({
|
||||
/>
|
||||
|
||||
<div className="alert-rule-item-name home-data-item-name">
|
||||
{dashboard.data.title}
|
||||
{dashboard.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-rule-item-description home-data-item-tag">
|
||||
{dashboard.data.tags?.map((tag) => (
|
||||
{dashboard.tags.map((tag) => (
|
||||
<Badge color="sienna" variant="outline" key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
|
||||
export type Dimensions = {
|
||||
@@ -15,6 +15,15 @@ export function useResizeObserver<T extends HTMLElement>(
|
||||
height: ref.current?.clientHeight || 0,
|
||||
});
|
||||
|
||||
// Measure before paint so the first frame has real dimensions, not 0 (the
|
||||
// debounced observer below only catches up after paint → layout jump).
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
if (node) {
|
||||
setSize({ width: node.clientWidth, height: node.clientHeight });
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = debounce(
|
||||
(entries: ResizeObserverEntry[]) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { KeyboardEvent } from 'react';
|
||||
import { type FocusEvent, KeyboardEvent } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Globe,
|
||||
@@ -90,12 +90,20 @@ function DashboardInfo({
|
||||
}
|
||||
};
|
||||
|
||||
// Clicking outside the editor commits, matching the input's Enter behaviour.
|
||||
// Guard against blurs that move focus to the Save/Cancel buttons within it.
|
||||
const onEditorBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
onCommit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardInfo}>
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
|
||||
{isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor}>
|
||||
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.trigger {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--l3-background);
|
||||
|
||||
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.options {
|
||||
min-width: min-content;
|
||||
}
|
||||
|
||||
.item {
|
||||
width: min-content;
|
||||
|
||||
span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@signozhq/ui/select';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
|
||||
import styles from './DashboardImagePicker.module.scss';
|
||||
|
||||
interface Props {
|
||||
// The selected image — one of the base64 icon data-URIs.
|
||||
image: string;
|
||||
onChange: (value: string) => void;
|
||||
// Consumers set the trigger's border-radius (e.g. rounded-left when joined to
|
||||
// a name input); this component owns size / background / icon-only styling.
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
// Icon picker shared by the dashboard-details settings and the create-dashboard
|
||||
// modal so both choose from the same `Base64Icons` set.
|
||||
function DashboardImagePicker({
|
||||
image,
|
||||
onChange,
|
||||
triggerClassName,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<Select value={image} onChange={(value): void => onChange(value as string)}>
|
||||
<SelectTrigger className={cx(styles.trigger, triggerClassName)} />
|
||||
<SelectContent className={styles.options} withPortal={false}>
|
||||
{Base64Icons.map((icon) => (
|
||||
<SelectItem key={icon} value={icon} className={styles.item}>
|
||||
<img src={icon} alt="dashboard-icon" className={styles.image} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardImagePicker;
|
||||
@@ -25,38 +25,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The image-picker owns its size/background; here we only round the left edge so
|
||||
// it joins flush with the name input to its right.
|
||||
.dashboardImageInput {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l3-background);
|
||||
|
||||
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardImageOptions {
|
||||
min-width: min-content;
|
||||
}
|
||||
|
||||
.dashboardImageSelectItem {
|
||||
width: min-content;
|
||||
|
||||
span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
|
||||
import { Input as AntdInput } from 'antd';
|
||||
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
import DashboardImagePicker from '../DashboardImagePicker/DashboardImagePicker';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
|
||||
import settingsStyles from '../../DashboardSettings.module.scss';
|
||||
import styles from './DashboardInfoForm.module.scss';
|
||||
@@ -43,30 +37,11 @@ function DashboardInfoForm({
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
value={image}
|
||||
onChange={(value): void => onImageChange(value as string)}
|
||||
>
|
||||
<SelectTrigger className={styles.dashboardImageInput} />
|
||||
<SelectContent
|
||||
className={styles.dashboardImageOptions}
|
||||
withPortal={false}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<SelectItem
|
||||
key={icon}
|
||||
value={icon}
|
||||
className={styles.dashboardImageSelectItem}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DashboardImagePicker
|
||||
image={image}
|
||||
onChange={onImageChange}
|
||||
triggerClassName={styles.dashboardImageInput}
|
||||
/>
|
||||
|
||||
<Input
|
||||
testId="dashboard-name"
|
||||
|
||||
@@ -83,7 +83,18 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
if (updatedImage !== image) {
|
||||
ops.push(replace('/image', updatedImage));
|
||||
// `replace` fails when the image doesn't exist yet, so add it when the
|
||||
// dashboard has none (`add` creates or replaces the member). Key off the
|
||||
// raw stored value, not the `Base64Icons[0]`-defaulted local `image`.
|
||||
ops.push(
|
||||
op(
|
||||
dashboard.image
|
||||
? DashboardtypesPatchOpDTO.replace
|
||||
: DashboardtypesPatchOpDTO.add,
|
||||
'/image',
|
||||
updatedImage,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!isEqual(updatedTags, tagsAsStrings)) {
|
||||
ops.push(replace('/tags', stringsToTags(updatedTags)));
|
||||
@@ -96,6 +107,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
description,
|
||||
updatedImage,
|
||||
image,
|
||||
dashboard.image,
|
||||
updatedTags,
|
||||
tagsAsStrings,
|
||||
]);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Pan
|
||||
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { LegendSeries } from '../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Input } from 'antd';
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import type { LegendSeries } from '../../../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../../../utils/legendSeries';
|
||||
import LegendColorRow from './LegendColorRow';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import LegendColors from '../LegendColors';
|
||||
|
||||
const SERIES: LegendSeries[] = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
filterLegendSeries,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { LegendSeries } from '../../../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../../../utils/legendSeries';
|
||||
|
||||
/** Case-insensitive substring filter over series labels. Empty query → all series. */
|
||||
export function filterLegendSeries(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelKind } from '../../Panels/types/panelKind';
|
||||
import type { LegendSeries } from '../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ interface ColumnUnitsProps {
|
||||
columns: TableColumnOption[];
|
||||
/** Current per-column unit map (`formatting.columnUnits`), keyed by column key. */
|
||||
value: Record<string, string>;
|
||||
/** Unit the selected metric was sent with; each column warns if its unit mismatches. */
|
||||
metricUnit?: string;
|
||||
onChange: (next: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
@@ -23,6 +25,7 @@ interface ColumnUnitsProps {
|
||||
function ColumnUnits({
|
||||
columns,
|
||||
value,
|
||||
metricUnit,
|
||||
onChange,
|
||||
}: ColumnUnitsProps): JSX.Element {
|
||||
if (columns.length === 0) {
|
||||
@@ -53,6 +56,7 @@ function ColumnUnits({
|
||||
placeholder="Select unit"
|
||||
source={YAxisSource.DASHBOARDS}
|
||||
value={value[column.key]}
|
||||
initialValue={metricUnit}
|
||||
containerClassName={styles.columnUnitSelector}
|
||||
onChange={(unit): void => setUnit(column.key, unit)}
|
||||
/>
|
||||
|
||||
@@ -81,6 +81,7 @@ function FormattingSection({
|
||||
<ColumnUnits
|
||||
columns={tableColumns}
|
||||
value={value?.columnUnits ?? {}}
|
||||
metricUnit={metricUnit}
|
||||
onChange={(columnUnits): void => onChange({ ...value, columnUnits })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
|
||||
import FormattingSection from '../FormattingSection';
|
||||
|
||||
// Auto-seeding is covered by useMetricYAxisUnit's tests; here `metricUnit` is just a prop.
|
||||
// Auto-seeding is covered by useSeedMetricUnit's tests; here `metricUnit` is just a prop.
|
||||
|
||||
// Open the Decimals select (clicking its antd selector) and pick the option with the
|
||||
// given visible label.
|
||||
@@ -100,4 +100,33 @@ describe('FormattingSection', () => {
|
||||
|
||||
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns when a column unit mismatches the metric unit', () => {
|
||||
// metric sent in seconds, but the column is set to bytes.
|
||||
render(
|
||||
<FormattingSection
|
||||
value={{ columnUnits: { A: 'By' } }}
|
||||
controls={{ columnUnits: true }}
|
||||
tableColumns={[{ key: 'A', label: 'A' }]}
|
||||
metricUnit="s"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('warning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no warning when the column unit matches the metric unit', () => {
|
||||
render(
|
||||
<FormattingSection
|
||||
value={{ columnUnits: { A: 's' } }}
|
||||
controls={{ columnUnits: true }}
|
||||
tableColumns={[{ key: 'A', label: 'A' }]}
|
||||
metricUnit="s"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,4 +7,9 @@
|
||||
|
||||
.dropdown {
|
||||
width: 260px;
|
||||
/* The dropdown portals to body; lift it above the query builder's antd
|
||||
* popups (1050) so it stays clickable, incl. inside the View modal.
|
||||
* TODO: remove after the antd -> @signozhq/ui migration
|
||||
**/
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ function AddColumnDropdown({
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent arrow side="top" align="end" className={styles.dropdown}>
|
||||
<ComboboxContent arrow side="bottom" align="end" className={styles.dropdown}>
|
||||
<ComboboxCommand shouldFilter={false}>
|
||||
<ComboboxInput
|
||||
value={searchText}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
padding: 12px;
|
||||
// Top padding lives on the sticky tab nav instead, so the nav keeps its
|
||||
// breathing room both at rest and when pinned to the top.
|
||||
padding: 0 12px 12px 12px;
|
||||
}
|
||||
|
||||
.tabsContainer {
|
||||
@@ -24,6 +26,15 @@
|
||||
background-color: var(--l1-background) !important;
|
||||
}
|
||||
:global(.ant-tabs-nav) {
|
||||
// Pin the query-type tabs + Run Query button to the top of the
|
||||
// `.container` scroll area while the query body scrolls underneath.
|
||||
// `padding-top` owns the nav's top spacing (moved off `.scrollArea`).
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
z-index: 1100;
|
||||
padding-top: 12px;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
&::before {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelEditorContainer from '../index';
|
||||
import { useScrollToPanelStore } from '../../store/useScrollToPanelStore';
|
||||
|
||||
/**
|
||||
* Characterization test for the editor's composition: which derived values and
|
||||
@@ -19,7 +20,7 @@ const mockRefetch = jest.fn();
|
||||
const mockCancelQuery = jest.fn();
|
||||
const mockBuildSaveSpec = jest.fn((spec: unknown) => spec);
|
||||
const mockOnChangePanelKind = jest.fn();
|
||||
const mockSave = jest.fn().mockResolvedValue(undefined);
|
||||
const mockSave = jest.fn().mockResolvedValue('panel-1');
|
||||
|
||||
const mockUseDraft = jest.fn();
|
||||
jest.mock('../hooks/usePanelEditorDraft', () => ({
|
||||
@@ -61,8 +62,8 @@ jest.mock('../hooks/useLegendSeries', () => ({
|
||||
jest.mock('../hooks/useTableColumns', () => ({
|
||||
useTableColumns: (): [] => [],
|
||||
}));
|
||||
jest.mock('../hooks/useMetricYAxisUnit', () => ({
|
||||
useMetricYAxisUnit: (): unknown => ({
|
||||
jest.mock('../hooks/useSeedMetricUnit', () => ({
|
||||
useSeedMetricUnit: (): unknown => ({
|
||||
metricUnit: undefined,
|
||||
isLoading: false,
|
||||
}),
|
||||
@@ -104,12 +105,17 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
const mockHeaderProps = jest.fn();
|
||||
jest.mock('../Header/Header', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { onSave: () => void }): JSX.Element => {
|
||||
default: (props: { onSave: () => void; onClose: () => void }): JSX.Element => {
|
||||
mockHeaderProps(props);
|
||||
return (
|
||||
<button type="button" data-testid="editor-save" onClick={props.onSave}>
|
||||
save
|
||||
</button>
|
||||
<>
|
||||
<button type="button" data-testid="editor-save" onClick={props.onSave}>
|
||||
save
|
||||
</button>
|
||||
<button type="button" data-testid="editor-close" onClick={props.onClose}>
|
||||
close
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
}));
|
||||
@@ -196,7 +202,10 @@ function setup(
|
||||
}
|
||||
|
||||
describe('PanelEditorContainer composition', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useScrollToPanelStore.setState({ scrollToPanelId: null });
|
||||
});
|
||||
|
||||
it('renders the editor shell with preview, query builder, and config pane', () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
@@ -299,6 +308,32 @@ describe('PanelEditorContainer composition', () => {
|
||||
expect(mockSave).toHaveBeenCalledWith(panel.spec);
|
||||
});
|
||||
|
||||
it('marks the saved panel to be scrolled into view on the dashboard', async () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-save'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(useScrollToPanelStore.getState().scrollToPanelId).toBe('panel-1'),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks an existing panel to be revealed when the editor is closed', async () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-close'));
|
||||
|
||||
expect(useScrollToPanelStore.getState().scrollToPanelId).toBe('panel-1');
|
||||
});
|
||||
|
||||
it('does not mark a scroll target when a new, unsaved panel is closed', async () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-close'));
|
||||
|
||||
expect(useScrollToPanelStore.getState().scrollToPanelId).toBeNull();
|
||||
});
|
||||
|
||||
it('offers Switch to View Mode for an existing panel', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { preparePieData } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { flattenTimeSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
@@ -29,18 +30,36 @@ jest.mock(
|
||||
() => ({
|
||||
flattenTimeSeries: jest.fn(),
|
||||
getTimeSeriesResults: jest.fn(() => []),
|
||||
getScalarResults: jest.fn(() => []),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables',
|
||||
() => ({ prepareScalarTables: jest.fn(() => []) }),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData',
|
||||
() => ({ preparePieData: jest.fn(() => []) }),
|
||||
);
|
||||
|
||||
const mockUseIsDarkMode = useIsDarkMode as unknown as jest.Mock;
|
||||
const mockFlatten = flattenTimeSeries as unknown as jest.Mock;
|
||||
const mockResolveLabel = resolveSeriesLabelV5 as unknown as jest.Mock;
|
||||
const mockGenerateColor = generateColor as unknown as jest.Mock;
|
||||
const mockPreparePie = preparePieData as unknown as jest.Mock;
|
||||
|
||||
const PANEL = {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} }, queries: [] },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
const PIE_PANEL = {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/PieChartPanel', spec: {} }, queries: [] },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
const HISTOGRAM_PANEL = {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/HistogramPanel', spec: {} }, queries: [] },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
const DATA = { response: {}, legendMap: {} } as unknown as PanelQueryData;
|
||||
|
||||
// Each flattened series carries the label resolveSeriesLabelV5 should report.
|
||||
@@ -88,6 +107,32 @@ describe('useLegendSeries', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves histogram panels via the time-series path', () => {
|
||||
mockFlatten.mockReturnValue(seriesWithLabels(['a', 'b']));
|
||||
const { result } = renderHook(() => useLegendSeries(HISTOGRAM_PANEL, DATA));
|
||||
expect(result.current).toStrictEqual([
|
||||
{ label: 'a', defaultColor: 'color:a' },
|
||||
{ label: 'b', defaultColor: 'color:b' },
|
||||
]);
|
||||
// The pie path must not run for a histogram panel.
|
||||
expect(mockPreparePie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves pie panels from their scalar slices, deduped by label', () => {
|
||||
mockPreparePie.mockReturnValue([
|
||||
{ label: 'x', color: 'c1' },
|
||||
{ label: 'y', color: 'c2' },
|
||||
{ label: 'x', color: 'c1' },
|
||||
]);
|
||||
const { result } = renderHook(() => useLegendSeries(PIE_PANEL, DATA));
|
||||
expect(result.current).toStrictEqual([
|
||||
{ label: 'x', defaultColor: 'c1' },
|
||||
{ label: 'y', defaultColor: 'c2' },
|
||||
]);
|
||||
// The time-series path must not run for a pie panel.
|
||||
expect(mockFlatten).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the dark palette in dark mode and the light palette otherwise', () => {
|
||||
mockFlatten.mockReturnValue(seriesWithLabels(['a']));
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
|
||||
import { useMetricYAxisUnit } from '../useMetricYAxisUnit';
|
||||
|
||||
jest.mock('hooks/useGetYAxisUnit', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
|
||||
|
||||
function mockMetricUnit(
|
||||
yAxisUnit: string | undefined,
|
||||
isLoading = false,
|
||||
): void {
|
||||
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
|
||||
}
|
||||
|
||||
describe('useMetricYAxisUnit', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('seeds the unit from the metric on a new panel', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
|
||||
);
|
||||
|
||||
expect(onSelectUnit).toHaveBeenCalledWith('bytes');
|
||||
});
|
||||
|
||||
it('does not seed when not a new panel', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useMetricYAxisUnit({ isNewPanel: false, unit: undefined, onSelectUnit }),
|
||||
);
|
||||
|
||||
expect(onSelectUnit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not seed when the metric has no unit', () => {
|
||||
mockMetricUnit(undefined);
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
|
||||
);
|
||||
|
||||
expect(onSelectUnit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not seed when the unit already matches the metric', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useMetricYAxisUnit({ isNewPanel: true, unit: 'bytes', onSelectUnit }),
|
||||
);
|
||||
|
||||
expect(onSelectUnit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-seeds when the resolved metric unit changes', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: { unit: string | undefined }) =>
|
||||
useMetricYAxisUnit({
|
||||
isNewPanel: true,
|
||||
unit: props.unit,
|
||||
onSelectUnit,
|
||||
}),
|
||||
{ initialProps: { unit: undefined as string | undefined } },
|
||||
);
|
||||
expect(onSelectUnit).toHaveBeenLastCalledWith('bytes');
|
||||
|
||||
// The metric changes; the panel now holds the previously-seeded unit.
|
||||
mockMetricUnit('ms');
|
||||
rerender({ unit: 'bytes' });
|
||||
|
||||
expect(onSelectUnit).toHaveBeenLastCalledWith('ms');
|
||||
});
|
||||
|
||||
it('returns the resolved metric unit and loading state', () => {
|
||||
mockMetricUnit('bytes', true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetricYAxisUnit({
|
||||
isNewPanel: false,
|
||||
unit: undefined,
|
||||
onSelectUnit: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.metricUnit).toBe('bytes');
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,8 @@ jest.mock('api/generated/services/dashboard', () => ({
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
|
||||
}));
|
||||
|
||||
jest.mock('uuid', () => ({ v4: (): string => 'minted-panel-id' }));
|
||||
|
||||
describe('usePanelEditorSave', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -44,7 +46,7 @@ describe('usePanelEditorSave', () => {
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
await result.current.save(spec);
|
||||
const savedPanelId = await result.current.save(spec);
|
||||
|
||||
expect(mockPatchAsync).toHaveBeenCalledWith([
|
||||
{
|
||||
@@ -53,6 +55,25 @@ describe('usePanelEditorSave', () => {
|
||||
value: spec,
|
||||
},
|
||||
]);
|
||||
// Editing resolves with the panel's own id.
|
||||
expect(savedPanelId).toBe('panel-9');
|
||||
});
|
||||
|
||||
it('mints and resolves with a fresh id when creating a new panel', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'new', isNew: true }),
|
||||
);
|
||||
|
||||
const spec = {
|
||||
display: { name: 'New panel' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
const savedPanelId = await result.current.save(spec);
|
||||
|
||||
expect(savedPanelId).toBe('minted-panel-id');
|
||||
expect(mockPatchAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces the patch in-flight state as isSaving', () => {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
|
||||
import type { TableColumnOption } from '../useTableColumns';
|
||||
import { useSeedMetricUnit } from '../useSeedMetricUnit';
|
||||
|
||||
jest.mock('hooks/useGetYAxisUnit', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
|
||||
|
||||
function mockMetricUnit(
|
||||
yAxisUnit: string | undefined,
|
||||
isLoading = false,
|
||||
): void {
|
||||
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
|
||||
}
|
||||
|
||||
function makeSpec(formatting?: unknown): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: formatting ? { formatting } : {},
|
||||
},
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
function unit(spec: DashboardtypesPanelSpecDTO): unknown {
|
||||
return (spec.plugin.spec as { formatting?: { unit?: unknown } }).formatting
|
||||
?.unit;
|
||||
}
|
||||
|
||||
function columnUnits(spec: DashboardtypesPanelSpecDTO): unknown {
|
||||
return (spec.plugin.spec as { formatting?: { columnUnits?: unknown } })
|
||||
.formatting?.columnUnits;
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumnOption[] = [
|
||||
{ key: 'A', label: 'A' },
|
||||
{ key: 'B', label: 'B' },
|
||||
];
|
||||
|
||||
const NO_COLUMNS: TableColumnOption[] = [];
|
||||
|
||||
describe('useSeedMetricUnit', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('panel-wide unit (controls.unit)', () => {
|
||||
it('seeds formatting.unit from the metric on a new panel', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(unit(onChangeSpec.mock.calls[0][0])).toBe('bytes');
|
||||
});
|
||||
|
||||
it('does not seed when not a new panel', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: false,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not seed when the unit already matches the metric', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: makeSpec({ unit: 'bytes' }),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-seeds when the resolved metric unit changes', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: { spec: DashboardtypesPanelSpecDTO }) =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: props.spec,
|
||||
onChangeSpec,
|
||||
}),
|
||||
{ initialProps: { spec: makeSpec() } },
|
||||
);
|
||||
expect(unit(onChangeSpec.mock.calls[0][0])).toBe('bytes');
|
||||
|
||||
// Metric changes; the panel now holds the previously-seeded unit.
|
||||
mockMetricUnit('ms');
|
||||
rerender({ spec: makeSpec({ unit: 'bytes' }) });
|
||||
|
||||
expect(unit(onChangeSpec.mock.calls[1][0])).toBe('ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-column units (controls.columnUnits)', () => {
|
||||
it('seeds every value column with the metric unit once columns resolve', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: { columns: TableColumnOption[] }) =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true },
|
||||
columns: props.columns,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
{ initialProps: { columns: NO_COLUMNS } },
|
||||
);
|
||||
// Waits for results to resolve the columns.
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ columns: COLUMNS });
|
||||
expect(columnUnits(onChangeSpec.mock.calls[0][0])).toStrictEqual({
|
||||
A: 'bytes',
|
||||
B: 'bytes',
|
||||
});
|
||||
});
|
||||
|
||||
it('never writes formatting.unit for a Table', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true, decimals: true },
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(unit(onChangeSpec.mock.calls[0][0])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('only fills columns without a unit yet, keeping the user-set one', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true },
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec({ columnUnits: { A: 'ms' } }),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(columnUnits(onChangeSpec.mock.calls[0][0])).toStrictEqual({
|
||||
A: 'ms',
|
||||
B: 'bytes',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write when every column already has a unit', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true },
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec({ columnUnits: { A: 'ms', B: 's' } }),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds once and does not re-run after the metric unit changes', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: { metric: string }) => {
|
||||
mockMetricUnit(props.metric);
|
||||
return useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true },
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
});
|
||||
},
|
||||
{ initialProps: { metric: 'bytes' } },
|
||||
);
|
||||
expect(onChangeSpec).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ metric: 'ms' });
|
||||
expect(onChangeSpec).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds nothing when the kind has no unit control (Histogram/List)', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: undefined,
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the resolved metric unit and loading state', () => {
|
||||
mockMetricUnit('bytes', true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: false,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.metricUnit).toBe('bytes');
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,19 @@
|
||||
import { useMemo } from 'react';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
export interface LegendSeries {
|
||||
/** Resolved display label — the key `legend.customColors` is indexed by. */
|
||||
label: string;
|
||||
/** The series' auto-assigned color, shown when no override is set. */
|
||||
defaultColor: string;
|
||||
}
|
||||
import {
|
||||
type LegendSeries,
|
||||
resolvePieLegendSeries,
|
||||
resolveTimeSeriesLegendSeries,
|
||||
} from '../utils/legendSeries';
|
||||
|
||||
/**
|
||||
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs, using the
|
||||
* exact label resolution the time-series renderer applies (`flattenTimeSeries` →
|
||||
* `resolveSeriesLabelV5`) and the same `generateColor` default. The legend-colors control
|
||||
* keys overrides by these labels, so they must match what the chart draws. Deduplicated,
|
||||
* order-preserving; empty until data arrives or for kinds without flat time-series data.
|
||||
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs so the
|
||||
* legend-colors control can key overrides by the exact labels the chart draws. Only the
|
||||
* kinds that expose a colors control resolve series (Pie from its scalar slices, Time
|
||||
* Series from its flat series); every other kind returns none.
|
||||
*/
|
||||
export function useLegendSeries(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
@@ -33,27 +22,15 @@ export function useLegendSeries(
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return useMemo(() => {
|
||||
const palette = isDarkMode
|
||||
? themeColors.chartcolors
|
||||
: themeColors.lightModeColor;
|
||||
const series = flattenTimeSeries(
|
||||
getTimeSeriesResults(data?.response),
|
||||
data.legendMap,
|
||||
);
|
||||
const builderQueries = getBuilderQueries(panel.spec.queries);
|
||||
|
||||
const byLabel = new Map<string, string>();
|
||||
series.forEach((s) => {
|
||||
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
|
||||
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
|
||||
if (label && !byLabel.has(label)) {
|
||||
byLabel.set(label, generateColor(label, palette));
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(byLabel, ([label, defaultColor]) => ({
|
||||
label,
|
||||
defaultColor,
|
||||
}));
|
||||
}, [panel.spec.queries, data.response, data.legendMap, isDarkMode]);
|
||||
switch (panel.spec.plugin.kind) {
|
||||
case 'signoz/PieChartPanel':
|
||||
return resolvePieLegendSeries(data, isDarkMode);
|
||||
case 'signoz/TimeSeriesPanel':
|
||||
case 'signoz/BarChartPanel':
|
||||
case 'signoz/HistogramPanel':
|
||||
return resolveTimeSeriesLegendSeries(panel.spec.queries, data, isDarkMode);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}, [panel.spec.plugin.kind, panel.spec.queries, data, isDarkMode]);
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
|
||||
interface UseMetricYAxisUnitArgs {
|
||||
/** Only a new panel auto-seeds; editing never overwrites the saved unit. */
|
||||
isNewPanel: boolean;
|
||||
unit: string | undefined;
|
||||
onSelectUnit: (unit: string) => void;
|
||||
}
|
||||
|
||||
interface UseMetricYAxisUnitResult {
|
||||
metricUnit: string | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the selected metric's unit and, on a new panel only, seeds the formatting unit
|
||||
* from it (V1 parity); returns the unit for the selector's mismatch warning.
|
||||
*/
|
||||
export function useMetricYAxisUnit({
|
||||
isNewPanel,
|
||||
unit,
|
||||
onSelectUnit,
|
||||
}: UseMetricYAxisUnitArgs): UseMetricYAxisUnitResult {
|
||||
const { yAxisUnit: metricUnit, isLoading } = useGetYAxisUnit();
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewPanel && metricUnit && metricUnit !== unit) {
|
||||
onSelectUnit(metricUnit);
|
||||
}
|
||||
// Re-seed only when the resolved metric unit changes, not on every unit edit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isNewPanel, metricUnit]);
|
||||
|
||||
return { metricUnit, isLoading };
|
||||
}
|
||||
@@ -23,7 +23,8 @@ interface UsePanelEditorSaveArgs {
|
||||
}
|
||||
|
||||
interface UsePanelEditorSaveApi {
|
||||
save: (spec: DashboardtypesPanelSpecDTO) => Promise<void>;
|
||||
/** Resolves with the saved panel's id (freshly minted when creating). */
|
||||
save: (spec: DashboardtypesPanelSpecDTO) => Promise<string>;
|
||||
isSaving: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
@@ -44,17 +45,20 @@ export function usePanelEditorSave({
|
||||
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
|
||||
|
||||
const save = useCallback(
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<string> => {
|
||||
let ops: DashboardtypesJSONPatchOperationDTO[];
|
||||
// The id a new panel is persisted under (surfaced so the caller can reveal it).
|
||||
let savedPanelId = panelId;
|
||||
if (isNew) {
|
||||
// Resolve the target section against the freshest dashboard we have.
|
||||
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
const cached =
|
||||
queryClient.getQueryData<GetDashboardV2200>(dashboardQueryKey);
|
||||
savedPanelId = uuid();
|
||||
ops = createPanelOps({
|
||||
layouts: cached?.data.spec.layouts ?? [],
|
||||
layoutIndex,
|
||||
panelId: uuid(),
|
||||
panelId: savedPanelId,
|
||||
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
|
||||
});
|
||||
} else {
|
||||
@@ -69,6 +73,7 @@ export function usePanelEditorSave({
|
||||
|
||||
// Optimistic cache write + settle refetch (replaces the manual invalidate).
|
||||
await patchAsync(ops);
|
||||
return savedPanelId;
|
||||
},
|
||||
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
import type {
|
||||
SectionControls,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import { readFormatting, writeFormatting } from '../utils/formattingSpec';
|
||||
import type { TableColumnOption } from './useTableColumns';
|
||||
|
||||
type FormattingControls = SectionControls[SectionKind.Formatting];
|
||||
|
||||
interface UseSeedMetricUnitArgs {
|
||||
/** Only a new panel auto-seeds; editing never overwrites a saved unit. */
|
||||
isNewPanel: boolean;
|
||||
/**
|
||||
* The current kind's Formatting controls — the single source of truth for which
|
||||
* field a metric unit seeds into: `unit` (panel-wide) vs `columnUnits` (Table).
|
||||
* A kind with neither (Histogram/List) seeds nothing.
|
||||
*/
|
||||
formattingControls: FormattingControls | undefined;
|
||||
/** Resolved value columns (Table only; empty before results arrive / for other kinds). */
|
||||
columns: TableColumnOption[];
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
interface UseSeedMetricUnitResult {
|
||||
metricUnit: string | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the selected metric's unit and, on a new panel only, seeds it into the panel's
|
||||
* formatting — into `formatting.unit` for kinds with a panel-wide unit control, or into
|
||||
* `formatting.columnUnits` (per value column) for a Table, which has no panel-wide unit.
|
||||
* The kind's Formatting `controls` decide which applies, mirroring `buildPluginSpec`'s
|
||||
* switch-time seeding so the two never diverge. Returns the unit for the FormattingSection's
|
||||
* mismatch warning.
|
||||
*/
|
||||
export function useSeedMetricUnit({
|
||||
isNewPanel,
|
||||
formattingControls,
|
||||
columns,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
}: UseSeedMetricUnitArgs): UseSeedMetricUnitResult {
|
||||
const { yAxisUnit: metricUnit, isLoading } = useGetYAxisUnit();
|
||||
|
||||
const seedsUnit = isNewPanel && !!formattingControls?.unit;
|
||||
const seedsColumnUnits = isNewPanel && !!formattingControls?.columnUnits;
|
||||
|
||||
// Panel-wide unit: seed (and re-seed) whenever the resolved metric unit changes. Kept
|
||||
// off `spec` so a manual unit edit doesn't re-run this and fight the user.
|
||||
useEffect(() => {
|
||||
if (!seedsUnit || !metricUnit || metricUnit === readFormatting(spec)?.unit) {
|
||||
return;
|
||||
}
|
||||
onChangeSpec(writeFormatting(spec, { unit: metricUnit }));
|
||||
// Re-seed only when the resolved metric unit changes, not on every unit edit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seedsUnit, metricUnit]);
|
||||
|
||||
// Per-column units (Table): seed once, only for columns without a unit yet, so it
|
||||
// never clobbers a user's edit or a cleared column. Waits for results to resolve them.
|
||||
const seededColumnsRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!seedsColumnUnits ||
|
||||
seededColumnsRef.current ||
|
||||
!metricUnit ||
|
||||
columns.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const columnUnits = readFormatting(spec)?.columnUnits ?? {};
|
||||
const unset = columns.filter(
|
||||
(column) => columnUnits[column.key] === undefined,
|
||||
);
|
||||
seededColumnsRef.current = true;
|
||||
if (unset.length === 0) {
|
||||
return;
|
||||
}
|
||||
const nextColumnUnits = { ...columnUnits };
|
||||
unset.forEach((column) => {
|
||||
nextColumnUnits[column.key] = metricUnit;
|
||||
});
|
||||
onChangeSpec(writeFormatting(spec, { columnUnits: nextColumnUnits }));
|
||||
// Seed once columns first resolve with a unit; not on later spec edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seedsColumnUnits, metricUnit, columns]);
|
||||
|
||||
return { metricUnit, isLoading };
|
||||
}
|
||||
@@ -8,25 +8,29 @@ import {
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
type DashboardtypesPanelFormattingDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
type SectionConfig,
|
||||
type SectionControls,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
|
||||
import { useScrollToPanelStore } from '../store/useScrollToPanelStore';
|
||||
import ConfigPane from './ConfigPane/ConfigPane';
|
||||
import Header from './Header/Header';
|
||||
import layoutStorage from './layoutStorage';
|
||||
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import { useLegendSeries } from './hooks/useLegendSeries';
|
||||
import { useMetricYAxisUnit } from './hooks/useMetricYAxisUnit';
|
||||
import { usePanelEditSession } from './hooks/usePanelEditSession';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
|
||||
@@ -124,32 +128,20 @@ function PanelEditorContainer({
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
|
||||
// At editor level, not the collapsible FormattingSection, so seeding runs while closed.
|
||||
const formattingUnit = (
|
||||
spec.plugin.spec as {
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
}
|
||||
).formatting?.unit;
|
||||
const seedFormattingUnit = useCallback(
|
||||
(unit: string): void => {
|
||||
const pluginSpec = spec.plugin.spec as {
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
};
|
||||
setSpec({
|
||||
...spec,
|
||||
plugin: {
|
||||
...spec.plugin,
|
||||
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, unit } },
|
||||
},
|
||||
} as DashboardtypesPanelSpecDTO);
|
||||
},
|
||||
[spec, setSpec],
|
||||
);
|
||||
const { metricUnit } = useMetricYAxisUnit({
|
||||
isNewPanel: isNew,
|
||||
unit: formattingUnit,
|
||||
onSelectUnit: seedFormattingUnit,
|
||||
});
|
||||
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
|
||||
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
|
||||
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
|
||||
const formattingControls = useMemo(():
|
||||
| SectionControls[SectionKind.Formatting]
|
||||
| undefined => {
|
||||
const section = panelDefinition.sections.find(
|
||||
(
|
||||
candidate,
|
||||
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
|
||||
candidate.kind === SectionKind.Formatting,
|
||||
);
|
||||
return section?.controls;
|
||||
}, [panelDefinition]);
|
||||
|
||||
// A new panel is savable once it has a query to run — List auto-seeds one; other
|
||||
// kinds open query-less, so there's nothing to save until the user builds one.
|
||||
@@ -188,6 +180,17 @@ function PanelEditorContainer({
|
||||
const legendSeries = useLegendSeries(draft, data);
|
||||
const tableColumns = useTableColumns(draft, data);
|
||||
|
||||
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
|
||||
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
|
||||
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
|
||||
const { metricUnit } = useSeedMetricUnit({
|
||||
isNewPanel: isNew,
|
||||
formattingControls,
|
||||
columns: tableColumns,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Smallest query step interval (seconds) — the floor for the span-gaps
|
||||
// threshold. Undefined until results carry step metadata.
|
||||
const stepInterval = useMemo((): number | undefined => {
|
||||
@@ -203,19 +206,33 @@ function PanelEditorContainer({
|
||||
query: currentQuery,
|
||||
});
|
||||
|
||||
const setScrollToPanelId = useScrollToPanelStore((s) => s.setScrollToPanelId);
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Bake the live query into the spec so unstaged edits are saved too.
|
||||
await save(buildSaveSpec(draft.spec));
|
||||
const savedPanelId = await save(buildSaveSpec(draft.spec));
|
||||
toast.success('Panel saved');
|
||||
// Reveal the saved panel once the dashboard re-renders.
|
||||
setScrollToPanelId(savedPanelId);
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('Failed to save panel');
|
||||
}
|
||||
}, [isEditable, save, buildSaveSpec, draft.spec, onSaved]);
|
||||
}, [isEditable, save, buildSaveSpec, draft.spec, setScrollToPanelId, onSaved]);
|
||||
|
||||
// Leaving an existing panel's editor (without saving) still returns to it, so
|
||||
// the dashboard lands on that panel rather than scrolled to the top. A new,
|
||||
// unsaved panel has no persisted target, so there's nothing to reveal.
|
||||
const onCloseEditor = useCallback((): void => {
|
||||
if (!isNew) {
|
||||
setScrollToPanelId(panelId);
|
||||
}
|
||||
onClose();
|
||||
}, [isNew, panelId, setScrollToPanelId, onClose]);
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
@@ -227,7 +244,7 @@ function PanelEditorContainer({
|
||||
readOnlyReason={editDisabledReason}
|
||||
onSave={onSave}
|
||||
onSwitchToView={onSwitchToView}
|
||||
onClose={onClose}
|
||||
onClose={onCloseEditor}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { readFormatting, writeFormatting } from '../formattingSpec';
|
||||
|
||||
function makeSpec(formatting?: unknown): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: formatting ? { formatting } : {},
|
||||
},
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
describe('formattingSpec', () => {
|
||||
it('reads the formatting slice (undefined when absent)', () => {
|
||||
expect(readFormatting(makeSpec())).toBeUndefined();
|
||||
expect(readFormatting(makeSpec({ unit: 'bytes' }))).toStrictEqual({
|
||||
unit: 'bytes',
|
||||
});
|
||||
});
|
||||
|
||||
it('merges the patch into the formatting slice, preserving other fields', () => {
|
||||
const next = writeFormatting(makeSpec({ decimalPrecision: '2' }), {
|
||||
unit: 'bytes',
|
||||
});
|
||||
expect(readFormatting(next)).toStrictEqual({
|
||||
decimalPrecision: '2',
|
||||
unit: 'bytes',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mutate the input spec', () => {
|
||||
const spec = makeSpec({ unit: 'ms' });
|
||||
writeFormatting(spec, { unit: 'bytes' });
|
||||
expect(readFormatting(spec)).toStrictEqual({ unit: 'ms' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelFormattingSlice } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
// `spec.plugin.spec` is a discriminated union over panel kinds; these helpers narrow
|
||||
// to the shared `formatting` slice via a single localized cast at the boundary, so
|
||||
// callers read/write it without repeating the spread.
|
||||
|
||||
export function readFormatting(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
): PanelFormattingSlice | undefined {
|
||||
return (spec.plugin.spec as { formatting?: PanelFormattingSlice }).formatting;
|
||||
}
|
||||
|
||||
/** Merges a partial formatting patch into the panel's `formatting` slice. */
|
||||
export function writeFormatting(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
patch: Partial<PanelFormattingSlice>,
|
||||
): DashboardtypesPanelSpecDTO {
|
||||
const pluginSpec = spec.plugin.spec as { formatting?: PanelFormattingSlice };
|
||||
return {
|
||||
...spec,
|
||||
plugin: {
|
||||
...spec.plugin,
|
||||
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, ...patch } },
|
||||
},
|
||||
} as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { preparePieData } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getScalarResults,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
export interface LegendSeries {
|
||||
/** Resolved display label — the key `legend.customColors` is indexed by. */
|
||||
label: string;
|
||||
/** The series' auto-assigned color, shown when no override is set. */
|
||||
defaultColor: string;
|
||||
}
|
||||
|
||||
type PanelQueries = DashboardtypesPanelDTO['spec']['queries'];
|
||||
|
||||
/**
|
||||
* Dedupes `labels` (first-seen order, empties dropped) into `{ label, defaultColor }`
|
||||
* pairs, resolving each unique label's color lazily via `colorFor` — so a repeated
|
||||
* label never resolves a second color.
|
||||
*/
|
||||
function buildLegendSeries(
|
||||
labels: readonly string[],
|
||||
colorFor: (label: string, index: number) => string,
|
||||
): LegendSeries[] {
|
||||
const byLabel = new Map<string, string>();
|
||||
labels.forEach((label, index) => {
|
||||
if (label && !byLabel.has(label)) {
|
||||
byLabel.set(label, colorFor(label, index));
|
||||
}
|
||||
});
|
||||
return Array.from(byLabel, ([label, defaultColor]) => ({
|
||||
label,
|
||||
defaultColor,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pie is fed by scalar results, not time series. Reuse the exact slices the renderer
|
||||
* draws (without overrides, so their colors are the defaults) so the color control keys
|
||||
* overrides by the same labels the chart does.
|
||||
*/
|
||||
export function resolvePieLegendSeries(
|
||||
data: PanelQueryData,
|
||||
isDarkMode: boolean,
|
||||
): LegendSeries[] {
|
||||
const slices = preparePieData({
|
||||
tables: prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap,
|
||||
requestPayload: data.requestPayload,
|
||||
}),
|
||||
isDarkMode,
|
||||
});
|
||||
return buildLegendSeries(
|
||||
slices.map((slice) => slice.label),
|
||||
(_, index) => slices[index].color,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-series kinds: resolve each flattened series' label the way the renderer does
|
||||
* (`getLabelName` → `resolveSeriesLabelV5`) and color it with `generateColor`.
|
||||
*/
|
||||
export function resolveTimeSeriesLegendSeries(
|
||||
queries: PanelQueries,
|
||||
data: PanelQueryData,
|
||||
isDarkMode: boolean,
|
||||
): LegendSeries[] {
|
||||
const palette = isDarkMode
|
||||
? themeColors.chartcolors
|
||||
: themeColors.lightModeColor;
|
||||
const builderQueries = getBuilderQueries(queries);
|
||||
const series = flattenTimeSeries(
|
||||
getTimeSeriesResults(data.response),
|
||||
data.legendMap,
|
||||
);
|
||||
return buildLegendSeries(
|
||||
series.map((s) =>
|
||||
resolveSeriesLabelV5(
|
||||
s,
|
||||
builderQueries,
|
||||
getLabelName(s.labels, s.queryName, s.legend),
|
||||
),
|
||||
),
|
||||
(label) => generateColor(label, palette),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CalendarRange, Clock, RotateCw } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { panelHasFixedTimePreference } from '../../../hooks/resolvePanelTimeWindow';
|
||||
import {
|
||||
selectViewPanelExtendWindow,
|
||||
useViewPanelStore,
|
||||
@@ -15,6 +17,8 @@ interface NoDataProps {
|
||||
isFetching?: boolean;
|
||||
/** When provided, renders a Retry button that re-runs the query. */
|
||||
onRetry?: () => void;
|
||||
/** Hides the global "Extend time range" action when this panel is locked to a fixed time preference. */
|
||||
panel?: DashboardtypesPanelDTO;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -29,19 +33,30 @@ function NoData({
|
||||
description = 'Nothing in the selected window. Try widening the range.',
|
||||
isFetching = false,
|
||||
onRetry,
|
||||
panel,
|
||||
'data-testid': testId = 'panel-no-data',
|
||||
}: NoDataProps): JSX.Element {
|
||||
const viewExtend = useViewPanelStore(selectViewPanelExtendWindow);
|
||||
const globalExtend = useExtendTimeWindow();
|
||||
const { canExtend, actionLabel, extend } = viewExtend ?? globalExtend;
|
||||
// The View modal's local extender wins; the global one only applies to a panel that
|
||||
// follows the ambient window (a fixed preference can't be widened by it).
|
||||
const hasFixedTimePreference = panel
|
||||
? panelHasFixedTimePreference(panel)
|
||||
: false;
|
||||
const activeExtend =
|
||||
viewExtend ?? (hasFixedTimePreference ? undefined : globalExtend);
|
||||
|
||||
if (isFetching) {
|
||||
return <PanelLoader />;
|
||||
}
|
||||
|
||||
const extendAction: PanelMessageAction | undefined =
|
||||
canExtend && actionLabel
|
||||
? { label: actionLabel, onClick: extend, icon: <CalendarRange size={14} /> }
|
||||
activeExtend?.canExtend && activeExtend.actionLabel
|
||||
? {
|
||||
label: activeExtend.actionLabel,
|
||||
onClick: activeExtend.extend,
|
||||
icon: <CalendarRange size={14} />,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const retryAction: PanelMessageAction | undefined = onRetry
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
type DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { useViewPanelStore } from '../../../../store/useViewPanelStore';
|
||||
@@ -24,6 +28,15 @@ function extender(over?: Partial<ExtendTimeWindow>): ExtendTimeWindow {
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal panel whose plugin spec carries the given time preference. */
|
||||
function panelWith(
|
||||
timePreference?: DashboardtypesTimePreferenceDTO,
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
spec: { plugin: { spec: { visualization: { timePreference } } } },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('NoData', () => {
|
||||
beforeEach(() => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(inert);
|
||||
@@ -119,4 +132,47 @@ describe('NoData', () => {
|
||||
|
||||
expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the global extend action for a panel with a fixed time preference', () => {
|
||||
const onRetry = jest.fn();
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(
|
||||
<NoData
|
||||
onRetry={onRetry}
|
||||
panel={panelWith(DashboardtypesTimePreferenceDTO.last_6_hr)}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Only Retry — extending the dashboard window can't widen a locked panel span.
|
||||
const action = screen.getByTestId('panel-no-data-action');
|
||||
expect(action).toHaveTextContent('Retry');
|
||||
expect(
|
||||
screen.queryByTestId('panel-no-data-secondary-action'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still offers the global extend action for a panel that follows the global window', () => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(
|
||||
<NoData panel={panelWith(DashboardtypesTimePreferenceDTO.global_time)} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent(
|
||||
'Extend time range',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the View modal extender even when the panel has a fixed time preference', () => {
|
||||
const storeExtend = jest.fn();
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
useViewPanelStore.setState({
|
||||
viewPanelExtendWindow: extender({ extend: storeExtend }),
|
||||
});
|
||||
render(
|
||||
<NoData panel={panelWith(DashboardtypesTimePreferenceDTO.last_6_hr)} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-no-data-action'));
|
||||
expect(storeExtend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,7 +193,7 @@ function BarPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
|
||||
@@ -13,7 +13,7 @@ export const sections: SectionConfig[] = [
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.LABEL },
|
||||
|
||||
@@ -106,7 +106,7 @@ function HistogramPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
|
||||
@@ -9,7 +9,7 @@ export const sections: SectionConfig[] = [
|
||||
},
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true },
|
||||
controls: { position: true, colors: true },
|
||||
// Merging all queries collapses to one distribution with no legend.
|
||||
isHidden: (spec): boolean =>
|
||||
Boolean(
|
||||
|
||||
@@ -143,7 +143,7 @@ function ListPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
|
||||
@@ -130,6 +130,7 @@ function NumberPanelRenderer({
|
||||
data-testid="number-panel-no-data"
|
||||
isFetching={isFetching}
|
||||
onRetry={refetch}
|
||||
panel={panel}
|
||||
/>
|
||||
) : (
|
||||
<ValueDisplay
|
||||
|
||||
@@ -95,7 +95,7 @@ function PiePanelRenderer({
|
||||
return (
|
||||
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
|
||||
{slices.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
) : (
|
||||
<Pie
|
||||
data={slices}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
|
||||
// Legend `colors` is omitted: the pie legend is always interactive swatches.
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a legend
|
||||
// (position + per-slice color overrides).
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
|
||||
@@ -160,7 +160,7 @@ function TablePanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
) : (
|
||||
<div className={styles.container}>
|
||||
<Table
|
||||
|
||||
@@ -194,7 +194,7 @@ function TimeSeriesPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
|
||||
@@ -47,9 +47,8 @@ describe('buildPluginSpec', () => {
|
||||
expect(buildPluginSpec([])).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('seeds nothing for sections with no seed (Axes, Buckets, ContextLinks)', () => {
|
||||
it('seeds nothing for sections with no seed (Buckets, ContextLinks)', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{ kind: SectionKind.Buckets, controls: { count: true, width: true } },
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
@@ -112,6 +111,108 @@ describe('buildPluginSpec', () => {
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('carries old timePreference / stacking / fillSpans the target declares', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: {
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
stacking: true,
|
||||
fillSpans: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
visualization: {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.last_6_hr,
|
||||
stackedBarChart: true,
|
||||
fillSpans: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).visualization).toStrictEqual({
|
||||
timePreference: DashboardtypesTimePreferenceDTO.last_6_hr,
|
||||
stackedBarChart: true,
|
||||
fillSpans: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops visualization fields the target does not declare (Bar → TimeSeries)', () => {
|
||||
// TimeSeries has no stacking control, so Bar's stackedBarChart must not carry.
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, timePreference: true, fillSpans: true },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
visualization: { stackedBarChart: true },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).visualization).toStrictEqual({
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries old legend position but never customColors', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
legend: {
|
||||
position: DashboardtypesLegendPositionDTO.right,
|
||||
customColors: { 'series-a': '#F1575F' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).legend).toStrictEqual({
|
||||
position: DashboardtypesLegendPositionDTO.right,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('axes seed (carry, gated by controls)', () => {
|
||||
it('carries softMin/softMax/isLogScale when the kind declares both controls', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
axes: { softMin: 0, softMax: 100, isLogScale: true },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).axes).toStrictEqual({
|
||||
softMin: 0,
|
||||
softMax: 100,
|
||||
isLogScale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries only the fields the target controls declare', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { logScale: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
axes: { softMin: 0, softMax: 100, isLogScale: true },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).axes).toStrictEqual({
|
||||
isLogScale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips null soft bounds and seeds nothing on a new panel or empty axes', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
expect(
|
||||
buildPluginSpec(sections, {
|
||||
oldSpec: oldSpecWith({ axes: { softMin: null, softMax: null } }),
|
||||
}),
|
||||
).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('chartAppearance seed', () => {
|
||||
@@ -137,6 +238,30 @@ describe('buildPluginSpec', () => {
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('carries old values over the defaults, gated by the declared controls', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.ChartAppearance,
|
||||
controls: { lineStyle: true, lineInterpolation: true, showPoints: true },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
chartAppearance: {
|
||||
lineStyle: DashboardtypesLineStyleDTO.dashed,
|
||||
fillMode: DashboardtypesFillModeDTO.gradient,
|
||||
showPoints: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).chartAppearance).toStrictEqual(
|
||||
{
|
||||
lineStyle: DashboardtypesLineStyleDTO.dashed,
|
||||
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
|
||||
showPoints: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatting seed (carry, gated by controls)', () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
/** Cross-section of the per-kind spec union; assigned to `plugin.spec` (unknown) at the boundary. */
|
||||
export interface SeededPluginSpec {
|
||||
visualization?: SectionSpecMap[SectionKind.Visualization];
|
||||
axes?: SectionSpecMap[SectionKind.Axes];
|
||||
legend?: SectionSpecMap[SectionKind.Legend];
|
||||
chartAppearance?: SectionSpecMap[SectionKind.ChartAppearance];
|
||||
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
|
||||
@@ -90,35 +91,103 @@ interface SectionSeed {
|
||||
const SECTION_SEEDS: Partial<Record<SectionKind, SectionSeed>> = {
|
||||
[SectionKind.Visualization]: {
|
||||
specKey: 'visualization',
|
||||
seed: (controls): SectionSpecMap[SectionKind.Visualization] | undefined => {
|
||||
seed: (
|
||||
controls,
|
||||
{ oldSpec },
|
||||
): SectionSpecMap[SectionKind.Visualization] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Visualization];
|
||||
return c.timePreference
|
||||
? { timePreference: DashboardtypesTimePreferenceDTO.global_time }
|
||||
: undefined;
|
||||
const old = (
|
||||
oldSpec?.plugin.spec as {
|
||||
visualization?: SectionSpecMap[SectionKind.Visualization];
|
||||
}
|
||||
)?.visualization;
|
||||
const visualization: SectionSpecMap[SectionKind.Visualization] = {
|
||||
...(c.timePreference && {
|
||||
timePreference:
|
||||
old?.timePreference ?? DashboardtypesTimePreferenceDTO.global_time,
|
||||
}),
|
||||
...(c.stacking &&
|
||||
old?.stackedBarChart !== undefined && {
|
||||
stackedBarChart: old.stackedBarChart,
|
||||
}),
|
||||
...(c.fillSpans &&
|
||||
old?.fillSpans !== undefined && { fillSpans: old.fillSpans }),
|
||||
};
|
||||
return Object.keys(visualization).length > 0 ? visualization : undefined;
|
||||
},
|
||||
},
|
||||
[SectionKind.Axes]: {
|
||||
specKey: 'axes',
|
||||
seed: (
|
||||
controls,
|
||||
{ oldSpec },
|
||||
): SectionSpecMap[SectionKind.Axes] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Axes];
|
||||
const old = (
|
||||
oldSpec?.plugin.spec as {
|
||||
axes?: SectionSpecMap[SectionKind.Axes];
|
||||
}
|
||||
)?.axes;
|
||||
if (!old) {
|
||||
return undefined;
|
||||
}
|
||||
const axes: SectionSpecMap[SectionKind.Axes] = {
|
||||
...(c.minMax &&
|
||||
typeof old.softMin === 'number' && { softMin: old.softMin }),
|
||||
...(c.minMax &&
|
||||
typeof old.softMax === 'number' && { softMax: old.softMax }),
|
||||
...(c.logScale &&
|
||||
old.isLogScale !== undefined && { isLogScale: old.isLogScale }),
|
||||
};
|
||||
return Object.keys(axes).length > 0 ? axes : undefined;
|
||||
},
|
||||
},
|
||||
[SectionKind.Legend]: {
|
||||
specKey: 'legend',
|
||||
seed: (controls): SectionSpecMap[SectionKind.Legend] | undefined => {
|
||||
seed: (
|
||||
controls,
|
||||
{ oldSpec },
|
||||
): SectionSpecMap[SectionKind.Legend] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Legend];
|
||||
const old = (
|
||||
oldSpec?.plugin.spec as {
|
||||
legend?: SectionSpecMap[SectionKind.Legend];
|
||||
}
|
||||
)?.legend;
|
||||
// customColors is keyed by series label, which the new kind may not reproduce.
|
||||
return c.position
|
||||
? { position: DashboardtypesLegendPositionDTO.bottom }
|
||||
? { position: old?.position ?? DashboardtypesLegendPositionDTO.bottom }
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
[SectionKind.ChartAppearance]: {
|
||||
specKey: 'chartAppearance',
|
||||
seed: (controls): SectionSpecMap[SectionKind.ChartAppearance] | undefined => {
|
||||
seed: (
|
||||
controls,
|
||||
{ oldSpec },
|
||||
): SectionSpecMap[SectionKind.ChartAppearance] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.ChartAppearance];
|
||||
const old = (
|
||||
oldSpec?.plugin.spec as {
|
||||
chartAppearance?: SectionSpecMap[SectionKind.ChartAppearance];
|
||||
}
|
||||
)?.chartAppearance;
|
||||
const appearance: SectionSpecMap[SectionKind.ChartAppearance] = {};
|
||||
if (c.lineStyle) {
|
||||
appearance.lineStyle = DashboardtypesLineStyleDTO.solid;
|
||||
appearance.lineStyle = old?.lineStyle ?? DashboardtypesLineStyleDTO.solid;
|
||||
}
|
||||
if (c.lineInterpolation) {
|
||||
appearance.lineInterpolation = DashboardtypesLineInterpolationDTO.spline;
|
||||
appearance.lineInterpolation =
|
||||
old?.lineInterpolation ?? DashboardtypesLineInterpolationDTO.spline;
|
||||
}
|
||||
if (c.fillMode) {
|
||||
appearance.fillMode = DashboardtypesFillModeDTO.none;
|
||||
appearance.fillMode = old?.fillMode ?? DashboardtypesFillModeDTO.none;
|
||||
}
|
||||
if (c.showPoints && old?.showPoints !== undefined) {
|
||||
appearance.showPoints = old.showPoints;
|
||||
}
|
||||
if (c.spanGaps && old?.spanGaps !== undefined) {
|
||||
appearance.spanGaps = old.spanGaps;
|
||||
}
|
||||
return Object.keys(appearance).length > 0 ? appearance : undefined;
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { panelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
import {
|
||||
getPanelTimePreference,
|
||||
panelTimePreferenceLabel,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
@@ -27,7 +27,7 @@ export interface PanelActionsConfig {
|
||||
interface PanelProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** True once this panel's section enters the viewport — gates the fetch. */
|
||||
/** True once this panel enters the viewport — gates the fetch (owned by SectionGridItem). */
|
||||
isVisible?: boolean;
|
||||
/** Move/delete actions — present only in editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
@@ -43,15 +43,7 @@ function Panel({
|
||||
isVisible,
|
||||
panelActions,
|
||||
}: PanelProps): JSX.Element {
|
||||
// A per-panel time preference is surfaced as a header pill. `visualization` is
|
||||
// common to every plugin-spec variant — localized cast reads it without
|
||||
// narrowing on kind.
|
||||
const timePreference = (
|
||||
panel.spec.plugin.spec as
|
||||
| { visualization?: { timePreference?: DashboardtypesTimePreferenceDTO } }
|
||||
| undefined
|
||||
)?.visualization?.timePreference;
|
||||
const timeLabel = panelTimePreferenceLabel(timePreference);
|
||||
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
|
||||
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
|
||||
@@ -89,6 +89,14 @@ function section(
|
||||
const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
|
||||
// Index 0 is the untitled root (free-flow) section; index 1 is a titled section.
|
||||
const TITLED_WITH_ROOT = [section(0, undefined), section(1, 'Latency')];
|
||||
// Untitled root plus two titled sections — exercises the multi-target submenu.
|
||||
const ROOT_AND_TWO_TITLED = [
|
||||
section(0, undefined),
|
||||
section(1, 'A'),
|
||||
section(2, 'B'),
|
||||
];
|
||||
// Just the free-flow root: an ungrouped board with no sections to move between.
|
||||
const ONLY_ROOT = [section(0, undefined)];
|
||||
|
||||
// Minimal panel — only its presence gates "Create Alerts"; the query→URL
|
||||
// translation it drives is covered by buildCreateAlertUrl's own tests.
|
||||
@@ -111,7 +119,9 @@ const baseArgs = {
|
||||
panelId: 'panel-1',
|
||||
panel: mockPanel,
|
||||
data: mockData,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
|
||||
// Panel sits in a titled section with an untitled root present, so every
|
||||
// action — including "Move to section" (→ Dashboard root) — is available.
|
||||
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
|
||||
};
|
||||
|
||||
function itemKeys(result: ReturnType<typeof usePanelActionItems>): unknown[] {
|
||||
@@ -210,7 +220,7 @@ describe('usePanelActionItems', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('move is disabled when there is no other titled section to move to', () => {
|
||||
it('hides "Move to section" when the only untitled section is not the root (index 0)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
@@ -220,8 +230,8 @@ describe('usePanelActionItems', () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find((i) => 'key' in i && i.key === 'move');
|
||||
expect(move).toMatchObject({ disabled: true });
|
||||
// An untitled section only counts as the root at layoutIndex 0.
|
||||
expect(itemKeys(result.current)).not.toContain('move');
|
||||
});
|
||||
|
||||
it('edit opens the panel editor for this panel', () => {
|
||||
@@ -233,14 +243,77 @@ describe('usePanelActionItems', () => {
|
||||
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1');
|
||||
});
|
||||
|
||||
it('move targets call the mutation with from/to layout indexes', () => {
|
||||
it('"Move to section" offers a single "Dashboard (root)" target', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as {
|
||||
children: { key: string; onClick: () => void }[];
|
||||
};
|
||||
expect(move.children).toHaveLength(1);
|
||||
expect(move.children.map((c) => c.key)).toStrictEqual(['move-to-root']);
|
||||
});
|
||||
|
||||
it('the "Dashboard (root)" target moves the panel to the untitled root section', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as {
|
||||
children: { onClick: () => void }[];
|
||||
};
|
||||
move.children[0].onClick();
|
||||
expect(mockMovePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
fromLayoutIndex: 1,
|
||||
toLayoutIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('an ungrouped panel (in the root) can move into each titled section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 0, sections: ROOT_AND_TWO_TITLED },
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as { children: { key: string; label: string }[] };
|
||||
expect(move.children.map((c) => c.key)).toStrictEqual(['move-1', 'move-2']);
|
||||
expect(move.children.map((c) => c.label)).toStrictEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('a panel in a titled section can move to the root and the other titled sections', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 1, sections: ROOT_AND_TWO_TITLED },
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as { children: { key: string; label: string }[] };
|
||||
// Root leads, then the other titled section — never the current one (A).
|
||||
expect(move.children.map((c) => c.key)).toStrictEqual([
|
||||
'move-to-root',
|
||||
'move-2',
|
||||
]);
|
||||
expect(move.children.map((c) => c.label)).toStrictEqual([
|
||||
'Dashboard (root)',
|
||||
'B',
|
||||
]);
|
||||
});
|
||||
|
||||
it('moves between titled sections even when the board has no untitled root', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as { children: { key: string; onClick: () => void }[] };
|
||||
expect(move.children.map((c) => c.key)).toStrictEqual(['move-1']);
|
||||
move.children[0].onClick();
|
||||
expect(mockMovePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
@@ -249,47 +322,14 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('offers "Move out of section" for a panel in a titled section when an untitled root exists', () => {
|
||||
it('hides "Move to section" when the board has no sections (only the root)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
|
||||
panelActions: { currentLayoutIndex: 0, sections: ONLY_ROOT },
|
||||
}),
|
||||
);
|
||||
expect(itemKeys(result.current)).toContain('move-to-root');
|
||||
});
|
||||
|
||||
it('"Move out of section" moves the panel to the untitled root section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
|
||||
}),
|
||||
);
|
||||
const moveOut = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move-to-root',
|
||||
);
|
||||
(moveOut as { onClick: () => void }).onClick();
|
||||
expect(mockMovePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
fromLayoutIndex: 1,
|
||||
toLayoutIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides "Move out of section" when the panel already sits in the root section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TITLED_WITH_ROOT },
|
||||
}),
|
||||
);
|
||||
expect(itemKeys(result.current)).not.toContain('move-to-root');
|
||||
});
|
||||
|
||||
it('hides "Move out of section" when every section is titled (no root)', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).not.toContain('move-to-root');
|
||||
expect(itemKeys(result.current)).not.toContain('move');
|
||||
});
|
||||
|
||||
it('delete defers to a confirmation: the item opens the dialog, confirm runs the mutation', async () => {
|
||||
@@ -312,7 +352,7 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
expect(mockDeletePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
layoutIndex: 0,
|
||||
layoutIndex: 1,
|
||||
});
|
||||
expect(result.current.deleteConfirm.open).toBe(false);
|
||||
});
|
||||
@@ -325,7 +365,7 @@ describe('usePanelActionItems', () => {
|
||||
(clone as { onClick: () => void }).onClick();
|
||||
expect(mockClonePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
layoutIndex: 0,
|
||||
layoutIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
cursor: grab;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { Info, Loader } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
@@ -74,6 +74,14 @@ function PanelHeader({
|
||||
[warning],
|
||||
);
|
||||
|
||||
/**
|
||||
* Hide the entire header when there's no title, description, or status to show,
|
||||
* and the actions menu is suppressed (editor preview).
|
||||
*/
|
||||
if (!name && !description && !errorDetail && !warningDetail && hideActions) {
|
||||
return <Fragment />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.header, 'panel-drag-handle')}>
|
||||
<div className={styles.headerLeft}>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.modal {
|
||||
:global(.ant-modal-body) {
|
||||
padding: 0px;
|
||||
}
|
||||
.dialog[data-width] {
|
||||
max-width: 85%;
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
// Truncate a long panel name so the header stays on one line; the wrapping tooltip
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@signozhq/ui/dialog';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Modal } from 'antd';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import ViewPanelModalContent from './ViewPanelModalContent';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
@@ -25,27 +33,48 @@ function ViewPanelModal({
|
||||
}: ViewPanelModalProps): JSX.Element {
|
||||
const name = panel?.spec.display.name ?? '';
|
||||
|
||||
// Render antd popups into the dialog (not document.body) so they stay inside the
|
||||
// modal's interactive, focus-trapped layer instead of being blocked by Radix.
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<Dialog
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
centered
|
||||
width="85%"
|
||||
destroyOnClose
|
||||
className={styles.modal}
|
||||
title={
|
||||
<TooltipSimple title={name} arrow>
|
||||
<Typography.Text className={styles.title}>
|
||||
{name} - (View mode)
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{open && panel && panelId && (
|
||||
<ViewPanelModalContent panel={panel} panelId={panelId} onClose={onClose} />
|
||||
)}
|
||||
</Modal>
|
||||
<DialogContent
|
||||
ref={contentRef}
|
||||
position="center"
|
||||
width="extra-wide"
|
||||
className={styles.dialog}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<TooltipSimple title={name} arrow>
|
||||
<Typography.Text className={styles.title}>
|
||||
{name ? `${name} - (View mode)` : 'View mode'}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogCloseButton />
|
||||
{open && panel && panelId && (
|
||||
<ConfigProvider
|
||||
getPopupContainer={(): HTMLElement => contentRef.current ?? document.body}
|
||||
>
|
||||
<ViewPanelModalContent
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import ListColumnsEditor from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ListColumnsEditor/ListColumnsEditor';
|
||||
import PanelEditorQueryBuilder from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
@@ -44,6 +45,7 @@ function ViewPanelModalContent({
|
||||
|
||||
const {
|
||||
draft,
|
||||
setSpec,
|
||||
panelDefinition,
|
||||
signal,
|
||||
queryType,
|
||||
@@ -54,7 +56,17 @@ function ViewPanelModalContent({
|
||||
buildSaveSpec,
|
||||
applyDrilldownQuery,
|
||||
} = useViewPanelMode({ panel, panelId, time: timeOverride });
|
||||
const { data, isFetching, error, refetch, cancelQuery, pagination } = query;
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
refetch,
|
||||
cancelQuery,
|
||||
pagination,
|
||||
} = query;
|
||||
|
||||
const isListPanel = draft.spec.plugin.kind === 'signoz/ListPanel';
|
||||
|
||||
// Grid drill-down, but filter-by-value / breakout refine this view in place. Drills the draft
|
||||
// so it reflects in-modal edits (and the click's time range follows the per-view window).
|
||||
@@ -119,6 +131,15 @@ function ViewPanelModalContent({
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
footer={
|
||||
isListPanel ? (
|
||||
<ListColumnsEditor
|
||||
spec={draft.spec}
|
||||
onChangeSpec={setSpec}
|
||||
signal={signal}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
@@ -128,6 +149,7 @@ function ViewPanelModalContent({
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
|
||||
@@ -35,6 +35,8 @@ interface UseViewPanelModeArgs {
|
||||
export interface UseViewPanelModeReturn {
|
||||
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
/** Update the draft's spec in place (e.g. the List columns editor). */
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Resolved renderer for the draft's current kind (registry always resolves a kind). */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
/**
|
||||
@@ -159,6 +161,7 @@ export function useViewPanelMode({
|
||||
|
||||
return {
|
||||
draft,
|
||||
setSpec,
|
||||
panelDefinition,
|
||||
signal,
|
||||
queryType: currentQuery.queryType,
|
||||
|
||||
@@ -19,6 +19,10 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
|
||||
jest.mock('uuid', () => ({ v4: (): string => 'cloned-id' }));
|
||||
|
||||
// jsdom has no layout engine, so scrollIntoView is undefined by default.
|
||||
const mockScrollIntoView = jest.fn();
|
||||
window.HTMLElement.prototype.scrollIntoView = mockScrollIntoView;
|
||||
|
||||
const sourcePanel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
@@ -132,6 +136,29 @@ describe('useClonePanel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('scrolls the cloned panel into view when the toast auto-closes', async () => {
|
||||
const clonedNode = document.createElement('div');
|
||||
clonedNode.setAttribute('data-panel-root', 'cloned-id');
|
||||
document.body.appendChild(clonedNode);
|
||||
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
// The scroll is deferred to the toast's auto-close, not fired inline.
|
||||
const { onAutoClose } = mockToastPromise.mock.calls[0][1] as {
|
||||
onAutoClose: () => void;
|
||||
};
|
||||
onAutoClose();
|
||||
|
||||
expect(mockScrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
|
||||
document.body.removeChild(clonedNode);
|
||||
});
|
||||
|
||||
it('swallows a patch rejection (toast owns the error UX) — does not throw', async () => {
|
||||
mockPatchAsync.mockRejectedValueOnce(new Error('boom'));
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { scrollIntoViewWhenReady } from 'utils/scrollIntoViewWhenReady';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
@@ -64,6 +65,14 @@ export function useClonePanel({
|
||||
success: 'Panel cloned',
|
||||
error: 'Failed to clone panel',
|
||||
position: 'top-center',
|
||||
duration: 2000,
|
||||
// Defer the scroll to the toast's auto-close so the "Panel cloned"
|
||||
// confirmation is seen first, then reveal the clone.
|
||||
onAutoClose: () => {
|
||||
scrollIntoViewWhenReady(() =>
|
||||
document.querySelector(`[data-panel-root="${newPanelId}"]`),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// toast.promise owns the error UX; swallow here to avoid an unhandled
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import { movePanelBetweenSectionsOps } from '../../../patchOps';
|
||||
import { findFreeSlot, movePanelBetweenSectionsOps } from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
|
||||
@@ -20,8 +20,8 @@ interface Params {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* stays in `spec.panels`; only the grid item moves, dropped into the target
|
||||
* section's first free slot (`findFreeSlot`). Persisted as one atomic patch.
|
||||
*/
|
||||
export function useMovePanelToSection({
|
||||
sections,
|
||||
@@ -52,12 +52,10 @@ export function useMovePanelToSection({
|
||||
}
|
||||
|
||||
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 }];
|
||||
// Reuse the shared placement primitive: fills the last row's free edge
|
||||
// if the panel fits, else drops to a fresh row at the bottom.
|
||||
const { x, y } = findFreeSlot(target.items, moved.width);
|
||||
const targetItems = [...target.items, { ...moved, x, y }];
|
||||
|
||||
try {
|
||||
await patchAsync(
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { FolderInput, FolderOutput } from '@signozhq/icons';
|
||||
import { FolderInput } from '@signozhq/icons';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import { findRootSection, type DashboardSection } from '../../../utils';
|
||||
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
|
||||
|
||||
// Matches the root option label in the New Panel picker (SectionPicker).
|
||||
const ROOT_LABEL = 'Dashboard (root)';
|
||||
|
||||
interface MoveItemsArgs {
|
||||
sections: DashboardSection[];
|
||||
currentLayoutIndex: number;
|
||||
@@ -12,9 +15,11 @@ interface MoveItemsArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Move to section" submenu plus a direct "Move out of section" to the
|
||||
* untitled root, shown only when the panel sits in a titled section and a root
|
||||
* section exists to receive it.
|
||||
* "Move to section" submenu listing every section the panel can move to — the
|
||||
* dashboard root (untitled top level, labelled "Dashboard (root)") plus every
|
||||
* titled section, minus the one the panel already sits in. Hidden entirely when
|
||||
* there is nowhere to move: an ungrouped board with no titled sections, or a
|
||||
* panel that is alone in the only section.
|
||||
*/
|
||||
export function buildMoveItems({
|
||||
sections,
|
||||
@@ -22,44 +27,38 @@ export function buildMoveItems({
|
||||
panelId,
|
||||
movePanel,
|
||||
}: MoveItemsArgs): MenuItem[] {
|
||||
const rootSection = findRootSection(sections);
|
||||
|
||||
// Sections are already in layout order, so the root (index 0, when present)
|
||||
// naturally leads. Untitled non-root layouts are never a move target.
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
|
||||
(section) =>
|
||||
section.layoutIndex !== currentLayoutIndex &&
|
||||
(section === rootSection || Boolean(section.title)),
|
||||
);
|
||||
const items: MenuItem[] = [
|
||||
|
||||
if (targets.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
...(targets.length === 0
|
||||
? { disabled: true }
|
||||
: {
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
children: targets.map((section) => {
|
||||
const isRoot = section === rootSection;
|
||||
return {
|
||||
key: isRoot ? 'move-to-root' : `move-${section.layoutIndex}`,
|
||||
label: isRoot ? ROOT_LABEL : (section.title as string),
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: section.layoutIndex,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const rootSection = findRootSection(sections);
|
||||
if (rootSection && rootSection.layoutIndex !== currentLayoutIndex) {
|
||||
items.push({
|
||||
key: 'move-to-root',
|
||||
label: 'Move out of section',
|
||||
icon: <FolderOutput size={14} />,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: rootSection.layoutIndex,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
|
||||
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import DisabledControlTooltip from '../../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
import { DASHBOARD_LOCKED_REASON } from '../../../hooks/useDashboardEditGuard';
|
||||
@@ -41,12 +39,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
targetLayoutIndex,
|
||||
} = useCreatePanel();
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
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({ sectionId: section.id });
|
||||
|
||||
@@ -77,17 +69,14 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
<SectionGrid
|
||||
items={section.items}
|
||||
layoutIndex={section.layoutIndex}
|
||||
isVisible={isVisible}
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!section.title) {
|
||||
// Untitled section — just the grid (no header chrome), but still observed
|
||||
// for the viewport signal.
|
||||
// Untitled section — just the grid, no header chrome.
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
>
|
||||
@@ -98,7 +87,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.section}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
|
||||
@@ -20,3 +20,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy-load observer boundary (SectionGridItem); fills the grid cell.
|
||||
.panelWrapper {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useMemo } from 'react';
|
||||
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import Panel from '../../Panel/Panel';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { usePersistLayout } from '../hooks/usePersistLayout';
|
||||
import SectionGridItem from './SectionGridItem';
|
||||
import styles from './SectionGrid.module.scss';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(GridLayout);
|
||||
@@ -12,8 +12,6 @@ const ResponsiveGridLayout = WidthProvider(GridLayout);
|
||||
interface SectionGridProps {
|
||||
items: DashboardSection['items'];
|
||||
layoutIndex: number;
|
||||
/** Forwarded to panels — true when the parent section is in the viewport. */
|
||||
isVisible?: boolean;
|
||||
/** All sections — layout context for the panel menu's move/delete actions. */
|
||||
sections?: DashboardSection[];
|
||||
}
|
||||
@@ -21,7 +19,6 @@ interface SectionGridProps {
|
||||
function SectionGrid({
|
||||
items,
|
||||
layoutIndex,
|
||||
isVisible,
|
||||
sections,
|
||||
}: SectionGridProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
@@ -61,10 +58,9 @@ function SectionGrid({
|
||||
// panel with no content.
|
||||
<div key={item.id}>
|
||||
{item.panel && (
|
||||
<Panel
|
||||
<SectionGridItem
|
||||
panel={item.panel}
|
||||
panelId={item.id}
|
||||
isVisible={isVisible}
|
||||
panelActions={
|
||||
isEditable
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useRef } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
|
||||
import Panel, { type PanelActionsConfig } from '../../Panel/Panel';
|
||||
import { useScrollPanelIntoView } from './useScrollPanelIntoView';
|
||||
import styles from './SectionGrid.module.scss';
|
||||
|
||||
const VIEWPORT_OBSERVER_OPTIONS: IntersectionObserverInit = {
|
||||
rootMargin: '200px',
|
||||
};
|
||||
|
||||
interface SectionGridItemProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-loads a single panel: watches its own viewport intersection (latched) and
|
||||
* passes it to the presentational Panel as `isVisible`, so a board of many panels
|
||||
* only fetches what's on screen.
|
||||
*/
|
||||
function SectionGridItem({
|
||||
panel,
|
||||
panelId,
|
||||
panelActions,
|
||||
}: SectionGridItemProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useIntersectionObserver(
|
||||
containerRef,
|
||||
VIEWPORT_OBSERVER_OPTIONS,
|
||||
true,
|
||||
);
|
||||
useScrollPanelIntoView(panelId, containerRef);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.panelWrapper}>
|
||||
<Panel
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
isVisible={isVisible}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionGridItem;
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useScrollToPanelStore } from '../../../../store/useScrollToPanelStore';
|
||||
import { useScrollPanelIntoView } from '../useScrollPanelIntoView';
|
||||
|
||||
function refWithScroll(scrollIntoView: jest.Mock): RefObject<HTMLElement> {
|
||||
return {
|
||||
current: { scrollIntoView } as unknown as HTMLElement,
|
||||
} as RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
describe('useScrollPanelIntoView', () => {
|
||||
beforeEach(() => {
|
||||
useScrollToPanelStore.setState({ scrollToPanelId: null });
|
||||
});
|
||||
|
||||
it('scrolls into view and clears the request when the store targets this panel', () => {
|
||||
const scrollIntoView = jest.fn();
|
||||
useScrollToPanelStore.setState({ scrollToPanelId: 'p1' });
|
||||
|
||||
renderHook(() => useScrollPanelIntoView('p1', refWithScroll(scrollIntoView)));
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
expect(useScrollToPanelStore.getState().scrollToPanelId).toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing when the store targets a different panel', () => {
|
||||
const scrollIntoView = jest.fn();
|
||||
useScrollToPanelStore.setState({ scrollToPanelId: 'other' });
|
||||
|
||||
renderHook(() => useScrollPanelIntoView('p1', refWithScroll(scrollIntoView)));
|
||||
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
expect(useScrollToPanelStore.getState().scrollToPanelId).toBe('other');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
|
||||
import { useScrollToPanelStore } from '../../../store/useScrollToPanelStore';
|
||||
|
||||
/**
|
||||
* Scrolls this panel into view when the store requests it (e.g. returning from
|
||||
* the editor or after creating a panel), then clears the request so it fires
|
||||
* once. Runs on mount, so it catches the panel as soon as the grid renders it.
|
||||
*/
|
||||
export function useScrollPanelIntoView(
|
||||
panelId: string,
|
||||
ref: RefObject<HTMLElement>,
|
||||
): void {
|
||||
const scrollToPanelId = useScrollToPanelStore((s) => s.scrollToPanelId);
|
||||
const setScrollToPanelId = useScrollToPanelStore((s) => s.setScrollToPanelId);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollToPanelId !== panelId) {
|
||||
return;
|
||||
}
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setScrollToPanelId(null);
|
||||
}, [scrollToPanelId, panelId, ref, setScrollToPanelId]);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useState } from 'react';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { scrollIntoViewWhenReady } from 'utils/scrollIntoViewWhenReady';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import {
|
||||
@@ -14,25 +15,6 @@ import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
|
||||
const SECTION_SELECTOR = '[data-testid^="dashboard-section-"]';
|
||||
|
||||
/**
|
||||
* Waits (via rAF) for the appended section to render, then scrolls it into view.
|
||||
* Polls because the optimistic cache write commits to the DOM a frame or two after
|
||||
* the patch call; bails after ~40 frames.
|
||||
*/
|
||||
function scrollToNewSection(prevCount: number, attempts = 40): void {
|
||||
const sections = document.querySelectorAll(SECTION_SELECTOR);
|
||||
if (sections.length > prevCount) {
|
||||
sections[sections.length - 1]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (attempts > 0) {
|
||||
requestAnimationFrame(() => scrollToNewSection(prevCount, attempts - 1));
|
||||
}
|
||||
}
|
||||
|
||||
interface Params {
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
}
|
||||
@@ -67,7 +49,13 @@ export function useAddSection({ layouts }: Params): Result {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchAsync([op]);
|
||||
scrollToNewSection(prevSectionCount);
|
||||
// Once the optimistic write appends the new section, scroll to it.
|
||||
scrollIntoViewWhenReady(() => {
|
||||
const sections = document.querySelectorAll(SECTION_SELECTOR);
|
||||
return sections.length > prevSectionCount
|
||||
? sections[sections.length - 1]
|
||||
: null;
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
@@ -6,6 +5,7 @@ import cx from 'classnames';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useInlineOverflowCount } from 'hooks/useInlineOverflowCount';
|
||||
|
||||
import { selectVariablesExpanded } from '../store/slices/collapseSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import AddVariableFull from './AddVariableFull';
|
||||
import AddVariableIcon from './AddVariableIcon';
|
||||
@@ -45,10 +45,13 @@ interface VariablesBarProps {
|
||||
* either way so auto-selection and option fetching keep driving the panels.
|
||||
*/
|
||||
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
const dashboardId = dashboard.id ?? '';
|
||||
const { variables, selection, setSelection, autoSelect } =
|
||||
useVariableSelection(dashboard);
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Persisted per dashboard so the full/collapsed view survives reloads.
|
||||
const expanded = useDashboardStore(selectVariablesExpanded(dashboardId));
|
||||
const setVariablesExpanded = useDashboardStore((s) => s.setVariablesExpanded);
|
||||
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
|
||||
itemCount: variables.length,
|
||||
gap: 8,
|
||||
@@ -75,7 +78,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
|
||||
aria-expanded={expanded}
|
||||
testId="dashboard-variables-more"
|
||||
onClick={(): void => setExpanded((prev) => !prev)}
|
||||
onClick={(): void => setVariablesExpanded(dashboardId, !expanded)}
|
||||
>
|
||||
{expanded ? 'Less' : `+${overflowCount}`}
|
||||
</Button>
|
||||
@@ -120,7 +123,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasOverflow && (
|
||||
{(expanded || hasOverflow) && (
|
||||
<span className={styles.moreButton}>
|
||||
{expanded ? (
|
||||
moreButton
|
||||
|
||||
@@ -57,6 +57,8 @@ function ValueSelector({
|
||||
onRetry={onRetry}
|
||||
showSearch
|
||||
placeholder="Select value"
|
||||
maxTagCount={2}
|
||||
maxTagTextLength={20}
|
||||
enableAllSelection={showAllOption}
|
||||
onChange={(next): void => {
|
||||
const values = Array.isArray(next)
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
cloneSectionOps,
|
||||
createDefaultPanel,
|
||||
createPanelOps,
|
||||
findFreeSlot,
|
||||
itemsOverlap,
|
||||
} from '../patchOps';
|
||||
|
||||
function item(y: number, height: number): DashboardGridItemDTO {
|
||||
@@ -143,6 +145,59 @@ describe('createPanelOps', () => {
|
||||
expect(ops[2].path).toBe('/spec/layouts/0/spec/items/-');
|
||||
expect((ops[2].value as DashboardGridItemDTO).y).toBe(0);
|
||||
});
|
||||
|
||||
it('wraps to the bottom when the last-row slot is blocked by a taller earlier-row panel', () => {
|
||||
// Regression: the last row (top-y 6) has room at x:3, but the tall right
|
||||
// panel spans y:0..12 into it. Placing at x:3,y:6 would overlap it, so the
|
||||
// panel must drop to a fresh row at the bottom (y:12) instead.
|
||||
const layouts = [
|
||||
section([
|
||||
itemAt(0, 0, 6, 6),
|
||||
itemAt(6, 0, 6, 12), // tall, reaches down into the last row
|
||||
itemAt(0, 6, 3, 6),
|
||||
]),
|
||||
];
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
|
||||
|
||||
const value = ops[1].value as DashboardGridItemDTO;
|
||||
expect(value.x).toBe(0);
|
||||
expect(value.y).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('itemsOverlap', () => {
|
||||
it('is true only when rectangles intersect on both axes', () => {
|
||||
const a = { x: 0, y: 0, width: 6, height: 6 };
|
||||
expect(itemsOverlap(a, { x: 3, y: 3, width: 6, height: 6 })).toBe(true);
|
||||
// Touching edges do not overlap (half-open intervals).
|
||||
expect(itemsOverlap(a, { x: 6, y: 0, width: 6, height: 6 })).toBe(false);
|
||||
expect(itemsOverlap(a, { x: 0, y: 6, width: 6, height: 6 })).toBe(false);
|
||||
// Overlaps on x only (disjoint on y) → no overlap.
|
||||
expect(itemsOverlap(a, { x: 3, y: 6, width: 6, height: 6 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findFreeSlot', () => {
|
||||
it('places the first item at the origin', () => {
|
||||
expect(findFreeSlot([], 6)).toStrictEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it('fills the right of the last row when it fits and is clear', () => {
|
||||
expect(findFreeSlot([itemAt(0, 0, 6, 6)], 6)).toStrictEqual({ x: 6, y: 0 });
|
||||
});
|
||||
|
||||
it('never returns a slot that overlaps an existing item', () => {
|
||||
const items = [itemAt(0, 0, 6, 6), itemAt(6, 0, 6, 12), itemAt(0, 6, 3, 6)];
|
||||
const slot = findFreeSlot(items, 6);
|
||||
const placed = { ...slot, width: 6, height: 6 };
|
||||
expect(items.some((it) => itemsOverlap(placed, it))).toBe(false);
|
||||
expect(slot).toStrictEqual({ x: 0, y: 12 });
|
||||
});
|
||||
|
||||
it('clamps a too-wide panel to the grid width', () => {
|
||||
// width 20 > 12 cols → clamped to 12, so it wraps below the first row.
|
||||
expect(findFreeSlot([itemAt(0, 0, 6, 6)], 20)).toStrictEqual({ x: 0, y: 6 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneSectionOps', () => {
|
||||
|
||||
@@ -1,74 +1,196 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { getDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useDashboardStaleCheck } from '../useDashboardStaleCheck';
|
||||
|
||||
const mockUseQuery = jest.fn();
|
||||
const mockUseIsMutating = jest.fn();
|
||||
jest.mock('react-query', () => ({
|
||||
useQuery: (...args: unknown[]): unknown => mockUseQuery(...args),
|
||||
useIsMutating: (): number => mockUseIsMutating(),
|
||||
useQueryClient: (): unknown => ({ getQueryData: jest.fn() }),
|
||||
}));
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
getDashboardV2: jest.fn(),
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/d1']),
|
||||
}));
|
||||
|
||||
function setServerUpdatedAt(updatedAt: string | undefined): void {
|
||||
mockUseQuery.mockReturnValue({ data: { data: { updatedAt } } });
|
||||
const mockGetDashboard = getDashboardV2 as jest.Mock;
|
||||
|
||||
const SPEC_A = { panels: { p1: {} } };
|
||||
const SPEC_B = { panels: { p1: {}, p2: {} } };
|
||||
|
||||
interface Content {
|
||||
spec?: unknown;
|
||||
tags?: unknown;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
// Defaults make loaded and server content identical unless a field is overridden.
|
||||
function withDefaults(content: Content): Record<string, unknown> {
|
||||
return { spec: SPEC_A, tags: [], locked: false, ...content };
|
||||
}
|
||||
|
||||
function loaded(
|
||||
updatedAt: string,
|
||||
content: Content = {},
|
||||
): DashboardtypesGettableDashboardV2DTO {
|
||||
return {
|
||||
id: 'd1',
|
||||
updatedAt,
|
||||
...withDefaults(content),
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function setVisibility(state: 'visible' | 'hidden'): void {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => state,
|
||||
});
|
||||
}
|
||||
|
||||
/** Simulate the tab/window coming back with the given server copy. */
|
||||
async function comeBack(
|
||||
serverUpdatedAt: string,
|
||||
content: Content = {},
|
||||
via: 'visibilitychange' | 'focus' = 'visibilitychange',
|
||||
): Promise<void> {
|
||||
mockGetDashboard.mockResolvedValueOnce({
|
||||
data: { updatedAt: serverUpdatedAt, ...withDefaults(content) },
|
||||
});
|
||||
setVisibility('visible');
|
||||
await act(async () => {
|
||||
if (via === 'focus') {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
} else {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('useDashboardStaleCheck', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseIsMutating.mockReturnValue(0);
|
||||
setVisibility('visible');
|
||||
});
|
||||
|
||||
it('prompts when the server copy is newer than the loaded one', () => {
|
||||
setServerUpdatedAt('2026-07-08T10:00:00Z');
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
|
||||
it('does not fetch on first load — only when the tab/window returns', async () => {
|
||||
renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
expect(result.current.showPrompt).toBe(true);
|
||||
expect(mockGetDashboard).not.toHaveBeenCalled();
|
||||
|
||||
await comeBack('2026-07-08T09:00:00Z');
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: 'd1' });
|
||||
});
|
||||
|
||||
it('does not prompt when the versions match', () => {
|
||||
setServerUpdatedAt('2026-07-08T09:00:00Z');
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
|
||||
it('also probes on window focus (returning from another app)', async () => {
|
||||
renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T09:00:00Z', {}, 'focus');
|
||||
expect(mockGetDashboard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prompts when newer and the spec differs', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
});
|
||||
|
||||
it('prompts when newer and the lock state differs (external lock)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(
|
||||
loaded('2026-07-08T09:00:00Z', { locked: false }),
|
||||
jest.fn(),
|
||||
),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', { locked: true });
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
});
|
||||
|
||||
it('prompts when newer and the tags differ', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(
|
||||
loaded('2026-07-08T09:00:00Z', { tags: [] }),
|
||||
jest.fn(),
|
||||
),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', {
|
||||
tags: [{ key: 'env', value: 'prod' }],
|
||||
});
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
});
|
||||
|
||||
it('does NOT prompt when newer but content (spec/tags/locked) is unchanged', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
// updatedAt bumped but every content field matches — e.g. our own lock, already
|
||||
// reflected in the render cache.
|
||||
await comeBack('2026-07-08T10:00:00Z');
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it('does not prompt while a mutation is in flight (optimistic save)', () => {
|
||||
it('does not prompt when the loaded copy is newer (own edit)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T10:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T09:00:00Z', { spec: SPEC_B });
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it('does not prompt while a mutation is in flight (optimistic save)', async () => {
|
||||
mockUseIsMutating.mockReturnValue(1);
|
||||
setServerUpdatedAt('2026-07-08T10:00:00Z');
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it('stops prompting for a version once dismissed', () => {
|
||||
setServerUpdatedAt('2026-07-08T10:00:00Z');
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
|
||||
it('does not probe while the tab is hidden', async () => {
|
||||
renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
expect(result.current.showPrompt).toBe(true);
|
||||
setVisibility('hidden');
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
expect(mockGetDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops prompting for a version once dismissed', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
act(() => result.current.dismiss());
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it('reload refetches and clears the dismissed state', () => {
|
||||
setServerUpdatedAt('2026-07-08T10:00:00Z');
|
||||
it('reload refetches and closes the prompt immediately', async () => {
|
||||
const refetch = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', refetch),
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), refetch),
|
||||
);
|
||||
act(() => result.current.dismiss());
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
act(() => result.current.reload());
|
||||
expect(refetch).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.showPrompt).toBe(true);
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it('re-prompts when a newer, different version appears after a reload', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
|
||||
act(() => result.current.reload());
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
|
||||
await comeBack('2026-07-08T10:05:00Z', { spec: { panels: { p3: {} } } });
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/** Absolute time window in epoch milliseconds — the V5 request's native unit. */
|
||||
export interface PanelTimeWindow {
|
||||
@@ -65,6 +68,28 @@ const TIME_PREFERENCE_LABEL: Partial<
|
||||
},
|
||||
};
|
||||
|
||||
/** A panel's saved `visualization.timePreference`, or `undefined` when the kind has none. */
|
||||
export function getPanelTimePreference(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
): DashboardtypesTimePreferenceDTO | undefined {
|
||||
const pluginSpec = panel.spec.plugin.spec;
|
||||
if (pluginSpec && 'visualization' in pluginSpec) {
|
||||
return pluginSpec.visualization?.timePreference;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** True when the panel is locked to a fixed relative window, so it ignores the dashboard's ambient time. */
|
||||
export function panelHasFixedTimePreference(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
): boolean {
|
||||
const timePreference = getPanelTimePreference(panel);
|
||||
return (
|
||||
timePreference !== undefined &&
|
||||
timePreference !== DashboardtypesTimePreferenceDTO.global_time
|
||||
);
|
||||
}
|
||||
|
||||
export interface PanelTimePreferenceLabel {
|
||||
/** Compact pill label, e.g. `6h`. */
|
||||
short: string;
|
||||
|
||||
@@ -1,65 +1,92 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useIsMutating, useQuery, useQueryClient } from 'react-query';
|
||||
import {
|
||||
getDashboardV2,
|
||||
getGetDashboardV2QueryKey,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useIsMutating } from 'react-query';
|
||||
|
||||
interface UseDashboardStaleCheck {
|
||||
// Server copy has a newer updatedAt than the loaded one, and not yet dismissed.
|
||||
// Server copy is a newer version with different content, and not yet dismissed.
|
||||
showPrompt: boolean;
|
||||
reload: () => void;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects when the open dashboard changed on the server (another tab/user) without
|
||||
* touching the render cache: a separate query (own key) refetches on window focus
|
||||
* and its updatedAt is compared to the loaded copy's. Guarded by in-flight
|
||||
* mutations so an optimistic save doesn't false-positive.
|
||||
* Prompts when another tab/user changed the open dashboard. The page's own query is frozen
|
||||
* (`staleTime: Infinity`), so we re-probe the server on tab/app return (not on first load) and
|
||||
* prompt only when it's strictly newer AND its content (spec, tags, or lock state) differs from
|
||||
* what we're viewing — so our own edits, which advance the render cache, don't trip it.
|
||||
* In-flight mutations are skipped.
|
||||
*/
|
||||
export function useDashboardStaleCheck(
|
||||
dashboardId: string,
|
||||
loadedUpdatedAt: string | undefined,
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
refetch: () => void,
|
||||
): UseDashboardStaleCheck {
|
||||
const {
|
||||
id: dashboardId,
|
||||
updatedAt: loadedUpdatedAt,
|
||||
spec: loadedSpec,
|
||||
tags: loadedTags,
|
||||
locked: loadedLocked,
|
||||
} = dashboard;
|
||||
const isMutating = useIsMutating() > 0;
|
||||
const queryClient = useQueryClient();
|
||||
const [server, setServer] = useState<DashboardtypesGettableDashboardV2DTO>();
|
||||
const [dismissedAt, setDismissedAt] = useState<string | null>(null);
|
||||
|
||||
const { data } = useQuery(
|
||||
['dashboard-freshness', dashboardId],
|
||||
({ signal }) => getDashboardV2({ id: dashboardId }, signal),
|
||||
{
|
||||
enabled: !!dashboardId,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: false,
|
||||
retry: false,
|
||||
// Seed from the already-loaded dashboard so mount makes no extra GET; the
|
||||
// query only hits the network on a later window focus.
|
||||
initialData: () =>
|
||||
queryClient.getQueryData<GetDashboardV2200>(
|
||||
getGetDashboardV2QueryKey({ id: dashboardId }),
|
||||
),
|
||||
},
|
||||
useEffect(() => {
|
||||
if (!dashboardId) {
|
||||
return undefined;
|
||||
}
|
||||
const probe = async (): Promise<void> => {
|
||||
// `visibilitychange` also fires on the hidden transition — only probe on return.
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await getDashboardV2({ id: dashboardId });
|
||||
setServer(response.data);
|
||||
} catch {
|
||||
// A failed freshness probe must never surface to the viewer.
|
||||
}
|
||||
};
|
||||
// `visibilitychange` catches tab switches; `focus` catches app/window switches.
|
||||
document.addEventListener('visibilitychange', probe);
|
||||
window.addEventListener('focus', probe);
|
||||
return (): void => {
|
||||
document.removeEventListener('visibilitychange', probe);
|
||||
window.removeEventListener('focus', probe);
|
||||
};
|
||||
}, [dashboardId]);
|
||||
|
||||
const serverUpdatedAt = server?.updatedAt;
|
||||
|
||||
// Content the viewer cares about: panels/layout/variables (spec), tags, and lock state.
|
||||
// Deep, order-independent so an optimistic patch's key reordering isn't read as a change.
|
||||
const contentDiffers = useMemo(
|
||||
() =>
|
||||
!!server &&
|
||||
(!isEqual(server.spec, loadedSpec) ||
|
||||
!isEqual(server.tags, loadedTags) ||
|
||||
server.locked !== loadedLocked),
|
||||
[server, loadedSpec, loadedTags, loadedLocked],
|
||||
);
|
||||
|
||||
const serverUpdatedAt = data?.data?.updatedAt;
|
||||
const changed =
|
||||
!isMutating &&
|
||||
!!serverUpdatedAt &&
|
||||
!!loadedUpdatedAt &&
|
||||
serverUpdatedAt !== loadedUpdatedAt;
|
||||
new Date(serverUpdatedAt).getTime() > new Date(loadedUpdatedAt).getTime() &&
|
||||
contentDiffers;
|
||||
|
||||
const dismiss = useCallback(
|
||||
(): void => setDismissedAt(serverUpdatedAt ?? null),
|
||||
[serverUpdatedAt],
|
||||
);
|
||||
// Dismiss + refetch: close on click and pull the latest; a newer, different version re-prompts.
|
||||
const reload = useCallback((): void => {
|
||||
setDismissedAt(serverUpdatedAt ?? null);
|
||||
refetch();
|
||||
setDismissedAt(null);
|
||||
}, [refetch]);
|
||||
}, [refetch, serverUpdatedAt]);
|
||||
|
||||
return {
|
||||
showPrompt: changed && serverUpdatedAt !== dismissedAt,
|
||||
|
||||
@@ -55,11 +55,7 @@ function DashboardContainer({
|
||||
// suggests them ($variable) in the panel editor and dashboards-page builder.
|
||||
useSyncVariablesForSuggestions(dashboard);
|
||||
|
||||
const staleCheck = useDashboardStaleCheck(
|
||||
dashboard.id,
|
||||
dashboard.updatedAt,
|
||||
refetch,
|
||||
);
|
||||
const staleCheck = useDashboardStaleCheck(dashboard, refetch);
|
||||
|
||||
// In full screen show only the sections and panels — the header/toolbar chrome
|
||||
// is hidden for a clean presentation view (exit with Esc).
|
||||
|
||||
@@ -172,9 +172,31 @@ const GRID_COLS = 12;
|
||||
type PlacedItem = Pick<DashboardGridItemDTO, 'x' | 'y' | 'width' | 'height'>;
|
||||
|
||||
/**
|
||||
* Placement for a new grid item: drop it right of the last row if there's room,
|
||||
* else wrap to a fresh row at the bottom. Only the last row is considered (items
|
||||
* sharing the greatest top-y); gaps in earlier rows are left alone.
|
||||
* Whether two grid rectangles intersect on both axes. Mirrors the backend's
|
||||
* overlap check (a patch placing two intersecting items is rejected), so this is
|
||||
* the authority the frontend must satisfy before adding an item.
|
||||
*/
|
||||
export function itemsOverlap(a: PlacedItem, b: PlacedItem): boolean {
|
||||
const ax = a.x ?? 0;
|
||||
const ay = a.y ?? 0;
|
||||
const aw = a.width ?? 0;
|
||||
const ah = a.height ?? 0;
|
||||
const bx = b.x ?? 0;
|
||||
const by = b.y ?? 0;
|
||||
const bw = b.width ?? 0;
|
||||
const bh = b.height ?? 0;
|
||||
return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placement for a new grid item: drop it right of the last row if it both fits
|
||||
* the grid width and clears every existing item, else wrap to a fresh row at the
|
||||
* bottom. Only the last row is preferred (items sharing the greatest top-y);
|
||||
* gaps in earlier rows are left alone. The overlap guard is what keeps this
|
||||
* safe — a tall panel from an earlier row can reach down into the last row, so
|
||||
* "right of the last row" is not automatically free. The bottom fallback sits
|
||||
* below every item and so can never overlap. This is the single placement
|
||||
* primitive for create / clone / move.
|
||||
*/
|
||||
export function findFreeSlot(
|
||||
items: PlacedItem[],
|
||||
@@ -194,7 +216,17 @@ export function findFreeSlot(
|
||||
.filter((it) => (it.y ?? 0) === lastRowY)
|
||||
.reduce((max, it) => Math.max(max, (it.x ?? 0) + (it.width ?? 0)), 0);
|
||||
|
||||
if (lastRowRightEdge + w <= GRID_COLS) {
|
||||
// height is unbounded downward, so use 1 for the fit probe: overlap on the
|
||||
// y-axis is decided by items reaching below `lastRowY`, not by the new
|
||||
// panel's own height (its top sits at the greatest top-y of all items).
|
||||
const candidate: PlacedItem = {
|
||||
x: lastRowRightEdge,
|
||||
y: lastRowY,
|
||||
width: w,
|
||||
height: 1,
|
||||
};
|
||||
const fitsWidth = lastRowRightEdge + w <= GRID_COLS;
|
||||
if (fitsWidth && !items.some((it) => itemsOverlap(candidate, it))) {
|
||||
return { x: lastRowRightEdge, y: lastRowY };
|
||||
}
|
||||
return { x: 0, y: bottom };
|
||||
|
||||
@@ -11,6 +11,10 @@ import type { DashboardStore } from '../useDashboardStore';
|
||||
export interface CollapseSlice {
|
||||
collapsed: Record<string, Record<string, boolean>>;
|
||||
toggleSectionCollapse: (dashboardId: string, sectionId: string) => void;
|
||||
// Whether the variables bar shows its full (expanded) list, per dashboard.
|
||||
// Absent → collapsed (the default).
|
||||
variablesExpanded: Record<string, boolean>;
|
||||
setVariablesExpanded: (dashboardId: string, expanded: boolean) => void;
|
||||
}
|
||||
|
||||
export const createCollapseSlice: StateCreator<
|
||||
@@ -32,4 +36,17 @@ export const createCollapseSlice: StateCreator<
|
||||
},
|
||||
});
|
||||
},
|
||||
variablesExpanded: {},
|
||||
setVariablesExpanded: (dashboardId, expanded): void => {
|
||||
const { variablesExpanded } = get();
|
||||
set({
|
||||
variablesExpanded: { ...variablesExpanded, [dashboardId]: expanded },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Selector: is the variables bar expanded for this dashboard? Default collapsed. */
|
||||
export const selectVariablesExpanded =
|
||||
(dashboardId: string) =>
|
||||
(state: DashboardStore): boolean =>
|
||||
Boolean(state.variablesExpanded[dashboardId]);
|
||||
|
||||
@@ -49,6 +49,7 @@ export const useDashboardStore = create<DashboardStore>()(
|
||||
// Persist UI-only state (context incl. the refetch fn is transient).
|
||||
partialize: (state) => ({
|
||||
collapsed: state.collapsed,
|
||||
variablesExpanded: state.variablesExpanded,
|
||||
variableValues: state.variableValues,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Ephemeral cross-route signal: the panel editor records which panel to reveal
|
||||
* on the way back, and the dashboard grid scrolls that panel into view once it
|
||||
* mounts, then clears the id. Kept out of `useDashboardStore` (never persisted)
|
||||
* and off the URL so a refresh doesn't re-trigger the scroll.
|
||||
*/
|
||||
export interface ScrollToPanelStore {
|
||||
scrollToPanelId: string | null;
|
||||
setScrollToPanelId: (panelId: string | null) => void;
|
||||
}
|
||||
|
||||
export const useScrollToPanelStore = create<ScrollToPanelStore>((set) => ({
|
||||
scrollToPanelId: null,
|
||||
setScrollToPanelId: (scrollToPanelId): void => {
|
||||
set({ scrollToPanelId });
|
||||
},
|
||||
}));
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
SquareArrowOutUpRight,
|
||||
Tag,
|
||||
} from '@signozhq/icons';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import DeleteActionItem from './DeleteActionItem';
|
||||
import EditTagsModal from './EditTagsModal';
|
||||
import RenameDashboardModal from './RenameDashboardModal';
|
||||
import styles from './ActionsPopover.module.scss';
|
||||
|
||||
@@ -39,6 +41,8 @@ interface Props {
|
||||
dashboardName: string;
|
||||
createdBy: string;
|
||||
isLocked: boolean;
|
||||
// Current tags as `key:value` strings, for the inline tag editor.
|
||||
tags: string[];
|
||||
// Edit permission (edit_dashboard). Read actions show regardless; edit actions are hidden without it.
|
||||
canEdit: boolean;
|
||||
onView: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
@@ -50,6 +54,7 @@ function ActionsPopover({
|
||||
dashboardName,
|
||||
createdBy,
|
||||
isLocked,
|
||||
tags,
|
||||
canEdit,
|
||||
onView,
|
||||
}: Props): JSX.Element {
|
||||
@@ -57,6 +62,7 @@ function ActionsPopover({
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const [isEditTagsOpen, setIsEditTagsOpen] = useState(false);
|
||||
|
||||
// Clone keeps the source's name/panels/tags as a new unlocked dashboard owned
|
||||
// by the caller; open the copy so it can be tweaked right away.
|
||||
@@ -165,6 +171,35 @@ function ActionsPopover({
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Tooltip
|
||||
placement="left"
|
||||
title={
|
||||
isLocked
|
||||
? 'This dashboard is locked, so its tags cannot be edited.'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<span className={styles.menuItemWrap}>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Tag size={14} />}
|
||||
disabled={isLocked}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!isLocked) {
|
||||
setIsEditTagsOpen(true);
|
||||
}
|
||||
}}
|
||||
testId="dashboard-action-edit-tags"
|
||||
>
|
||||
{tags.length > 0 ? 'Edit Tags' : 'Add Tags'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
color="secondary"
|
||||
@@ -231,6 +266,12 @@ function ActionsPopover({
|
||||
currentName={dashboardName}
|
||||
onClose={(): void => setIsRenameOpen(false)}
|
||||
/>
|
||||
<EditTagsModal
|
||||
open={isEditTagsOpen}
|
||||
dashboardId={dashboardId}
|
||||
currentTags={tags}
|
||||
onClose={(): void => setIsEditTagsOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
invalidateListDashboardsForUserV2,
|
||||
// eslint-disable-next-line no-restricted-imports -- list tag-edit targets another dashboard by id; useOptimisticPatch is bound to the open dashboard's store/cache.
|
||||
patchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { keyValueStringsToTags } from '../../utils/helpers';
|
||||
import styles from './ActionsPopover.module.scss';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
dashboardId: string;
|
||||
// Current tags as `key:value` strings.
|
||||
currentTags: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits a dashboard's tags from the list via an `add /tags` patch, then refreshes
|
||||
* the list. `add` is used (not `replace`) so it works whether the tags field is
|
||||
* already present or absent on the stored dashboard.
|
||||
*/
|
||||
function EditTagsModal({
|
||||
open,
|
||||
dashboardId,
|
||||
currentTags,
|
||||
onClose,
|
||||
}: Props): JSX.Element {
|
||||
const [tags, setTags] = useState<string[]>(currentTags);
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
// Reset to the dashboard's tags when the modal opens. Keyed by content
|
||||
// (`tagsKey`) rather than array identity, since `currentTags` is a fresh array
|
||||
// on each parent render; reconstructing from the key keeps the effect deps
|
||||
// stable and the reset off the per-render path.
|
||||
const tagsKey = currentTags.join('\n');
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTags(tagsKey ? tagsKey.split('\n') : []);
|
||||
}
|
||||
}, [open, tagsKey]);
|
||||
|
||||
const { mutate: runSave, isLoading } = useMutation({
|
||||
mutationFn: () => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: 'add' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/tags',
|
||||
value: keyValueStringsToTags(tags),
|
||||
},
|
||||
];
|
||||
return patchDashboardV2({ id: dashboardId }, ops);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('Tags updated');
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
onClose();
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
});
|
||||
|
||||
// Save on Cmd/Ctrl+Enter from anywhere in the modal (not just the tag input),
|
||||
// while it is open.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runSave();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return (): void => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open, runSave]);
|
||||
|
||||
const title = currentTags.length > 0 ? 'Edit tags' : 'Add tags';
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title={title}
|
||||
open={open}
|
||||
width="narrow"
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
footer={
|
||||
<div className={styles.renameFooter}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
testId="edit-tags-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
onClick={(): void => runSave()}
|
||||
testId="edit-tags-submit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TagKeyValueInput
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
placeholder="key:value (press Enter)"
|
||||
testId="edit-dashboard-tags"
|
||||
/>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditTagsModal;
|
||||
@@ -60,6 +60,12 @@ function DashboardRow({
|
||||
);
|
||||
|
||||
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
|
||||
// Clicks inside portaled overlays (the actions menu, edit modals) bubble here
|
||||
// through React's tree even though they render outside the row in the DOM.
|
||||
// Only navigate when the click actually landed inside the row.
|
||||
if (!event.currentTarget.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
markViewed(id);
|
||||
safeNavigate(link, { newTab: isModifierKeyPressed(event) });
|
||||
@@ -159,6 +165,7 @@ function DashboardRow({
|
||||
dashboardName={name}
|
||||
createdBy={createdBy}
|
||||
isLocked={isLocked}
|
||||
tags={tags}
|
||||
canEdit={canEdit}
|
||||
onView={onClickHandler}
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,8 @@ import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
|
||||
import { keyValueStringsToTags } from '../../utils/helpers';
|
||||
|
||||
import DashboardImagePicker from '../../../DashboardPageV2/DashboardContainer/DashboardSettings/Overview/DashboardImagePicker/DashboardImagePicker';
|
||||
import { Base64Icons } from '../../../DashboardPageV2/DashboardContainer/DashboardSettings/Overview/utils';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../DashboardPageV2/DashboardContainer/constants';
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
@@ -32,6 +34,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
|
||||
const [name, setName] = useState(DEFAULT_NAME);
|
||||
const [description, setDescription] = useState('');
|
||||
const [image, setImage] = useState<string>(Base64Icons[0]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -48,6 +51,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
const created = await createDashboardV2({
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
image,
|
||||
tags: postableTags.length ? postableTags : null,
|
||||
spec: {
|
||||
display: {
|
||||
@@ -77,21 +81,29 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
<Typography.Text className={styles.label}>
|
||||
Title <Typography.Text className={styles.required}>*</Typography.Text>
|
||||
</Typography.Text>
|
||||
<Input
|
||||
value={name}
|
||||
autoFocus
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
placeholder="e.g. Sample Dashboard"
|
||||
testId="create-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleCreate();
|
||||
<div className={styles.titleRow}>
|
||||
<DashboardImagePicker
|
||||
image={image}
|
||||
onChange={setImage}
|
||||
triggerClassName={styles.imageTrigger}
|
||||
/>
|
||||
<Input
|
||||
className={styles.titleInput}
|
||||
value={name}
|
||||
autoFocus
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
placeholder="e.g. Sample Dashboard"
|
||||
testId="create-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
|
||||
@@ -35,6 +35,28 @@
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
// Title row: icon picker joined flush to the left of the name input.
|
||||
.titleRow {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
// The icon picker renders its dropdown inline (no portal); lift it above the
|
||||
// description/tags fields below it.
|
||||
:global([data-radix-popper-content-wrapper]) {
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.imageTrigger {
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
|
||||
.titleInput {
|
||||
flex: 1;
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
@@ -60,28 +82,64 @@
|
||||
}
|
||||
}
|
||||
|
||||
.cardName {
|
||||
color: var(--l1-foreground);
|
||||
// "From a template" tab: a centered browse-and-request placeholder until the
|
||||
// templates BE lands. Fixed height matches .panel so switching tabs won't resize.
|
||||
.templatesPanel {
|
||||
height: 460px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 6px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.templatesIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 10px;
|
||||
color: var(--bg-robin-400);
|
||||
background: var(--callout-primary-background);
|
||||
border: 1px solid var(--callout-primary-border);
|
||||
}
|
||||
|
||||
.templatesDesc {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.browseLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
color: var(--bg-robin-400);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.requestRow {
|
||||
border-top: 1px solid var(--l2-border);
|
||||
padding-top: 12px;
|
||||
|
||||
:global(.request-entity-container) {
|
||||
gap: 10px 16px;
|
||||
padding: 14px 16px;
|
||||
&:hover {
|
||||
color: var(--bg-robin-300);
|
||||
}
|
||||
}
|
||||
|
||||
.requestForm {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.requestInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.importHeader {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l2-foreground);
|
||||
@@ -108,73 +166,6 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.templatesLayout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.templatesList {
|
||||
flex: none;
|
||||
width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.templateItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s,
|
||||
border-color 0.12s;
|
||||
}
|
||||
|
||||
.templateItem:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.templateItemActive {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--primary-background);
|
||||
}
|
||||
|
||||
.templateName {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.templateCat {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.templatesPreview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.previewHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.jsonError {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -43,7 +43,7 @@ function NewDashboardModal({ open, onClose }: Props): JSX.Element {
|
||||
{
|
||||
key: 'template',
|
||||
label: 'From a template',
|
||||
children: <TemplatesPanel onClose={onClose} />,
|
||||
children: <TemplatesPanel />,
|
||||
},
|
||||
{
|
||||
key: 'import',
|
||||
|
||||
@@ -1,67 +1,120 @@
|
||||
import { useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
LayoutDashboard,
|
||||
LoaderCircle,
|
||||
SquareArrowOutUpRight,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { AxiosError } from 'axios';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import DashboardTemplatesContent from 'container/ListOfDashboard/DashboardTemplates/DashboardTemplatesContent';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
const TEMPLATES_DOCS_URL =
|
||||
'https://signoz.io/docs/dashboards/dashboard-templates/overview/';
|
||||
|
||||
// Until the templates BE API lands, the V2 "From a template" tab embeds the V1
|
||||
// template gallery inline (no modal-in-modal). The V1 templates are placeholders,
|
||||
// so the action creates a blank dashboard.
|
||||
function TemplatesPanel({ onClose }: Props): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const [creating, setCreating] = useState(false);
|
||||
// Templates aren't served by the BE yet, so this tab is a browse-and-request
|
||||
// placeholder: link out to the published template library, and let cloud users
|
||||
// request one we haven't built.
|
||||
function TemplatesPanel(): JSX.Element {
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
const [name, setName] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (creating) {
|
||||
const requestName = name.trim();
|
||||
|
||||
const handleRequest = async (): Promise<void> => {
|
||||
if (!requestName || submitting) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCreating(true);
|
||||
logEvent('Dashboard List: Use template clicked', {});
|
||||
const created = await createDashboardV2({
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
tags: null,
|
||||
spec: {
|
||||
display: { name: 'Sample Dashboard' },
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
},
|
||||
setSubmitting(true);
|
||||
const response = await logEvent('Dashboard Requested', {
|
||||
screen: 'Dashboard list page',
|
||||
dashboard: requestName,
|
||||
});
|
||||
onClose();
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorModal(e as APIError);
|
||||
toast.error((e as AxiosError).toString() || 'Failed to create dashboard');
|
||||
setCreating(false);
|
||||
if (response.statusCode === 200) {
|
||||
toast.success('Dashboard request submitted');
|
||||
setName('');
|
||||
} else {
|
||||
toast.error(response.error || 'Something went wrong');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Something went wrong');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className="new-dashboard-templates-modal">
|
||||
<DashboardTemplatesContent
|
||||
onCreateNewDashboard={(): void => {
|
||||
void handleCreate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.templatesPanel}>
|
||||
<span className={styles.templatesIcon}>
|
||||
<LayoutDashboard size={20} />
|
||||
</span>
|
||||
<Typography variant="title" size="lg" weight="semibold">
|
||||
Dashboard templates
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="text"
|
||||
size="sm"
|
||||
color="muted"
|
||||
className={styles.templatesDesc}
|
||||
>
|
||||
Browse our library of ready-made dashboards, or request a new one and
|
||||
we'll build it for you.
|
||||
</Typography>
|
||||
|
||||
<a
|
||||
className={styles.browseLink}
|
||||
href={TEMPLATES_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Browse dashboard templates
|
||||
<SquareArrowOutUpRight size={14} />
|
||||
</a>
|
||||
|
||||
{isCloudUser && (
|
||||
<div className={styles.requestForm}>
|
||||
<Input
|
||||
className={styles.requestInput}
|
||||
placeholder="Enter dashboard name..."
|
||||
value={name}
|
||||
testId="request-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleRequest();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={submitting || requestName.length === 0}
|
||||
testId="request-dashboard-submit"
|
||||
prefix={
|
||||
submitting ? (
|
||||
<LoaderCircle size={14} className={styles.spinner} />
|
||||
) : (
|
||||
<Check size={14} />
|
||||
)
|
||||
}
|
||||
onClick={(): void => {
|
||||
void handleRequest();
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ export type DashboardListItem = DashboardtypesListedDashboardForUserV2DTO;
|
||||
export const tagsToStrings = (
|
||||
tags: { key: string; value: string }[] | null | undefined,
|
||||
): string[] =>
|
||||
(tags ?? []).map((tag) =>
|
||||
tag.key === tag.value ? tag.key : `${tag.key}:${tag.value}`,
|
||||
);
|
||||
// Always render both sides (so an env:env tag reads "env:env", not a lone
|
||||
// "env"); fall back to the bare key only when there's no value.
|
||||
(tags ?? []).map((tag) => (tag.value ? `${tag.key}:${tag.value}` : tag.key));
|
||||
|
||||
// Convert validated `key:value` tag strings (from TagKeyValueInput) into the
|
||||
// postable tag DTO shape. The first colon separates key from value.
|
||||
|
||||
56
frontend/src/utils/__tests__/scrollIntoViewWhenReady.test.ts
Normal file
56
frontend/src/utils/__tests__/scrollIntoViewWhenReady.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { scrollIntoViewWhenReady } from '../scrollIntoViewWhenReady';
|
||||
|
||||
describe('scrollIntoViewWhenReady', () => {
|
||||
let rafSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
// Run rAF callbacks synchronously so the poll loop resolves within the test.
|
||||
rafSpy = jest
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((cb: FrameRequestCallback): number => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rafSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('scrolls the resolved element into view immediately when it is ready', () => {
|
||||
const scrollIntoView = jest.fn();
|
||||
const el = { scrollIntoView } as unknown as Element;
|
||||
|
||||
scrollIntoViewWhenReady(() => el);
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
expect(rafSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('polls via rAF until the target appears, then scrolls it once', () => {
|
||||
const scrollIntoView = jest.fn();
|
||||
const el = { scrollIntoView } as unknown as Element;
|
||||
let calls = 0;
|
||||
const resolveTarget = jest.fn(() => {
|
||||
calls += 1;
|
||||
return calls >= 3 ? el : null;
|
||||
});
|
||||
|
||||
scrollIntoViewWhenReady(resolveTarget);
|
||||
|
||||
expect(resolveTarget).toHaveBeenCalledTimes(3);
|
||||
expect(scrollIntoView).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bails after the attempt budget when the target never appears', () => {
|
||||
const resolveTarget = jest.fn(() => null);
|
||||
|
||||
scrollIntoViewWhenReady(resolveTarget, 5);
|
||||
|
||||
// Initial call + 5 rAF retries.
|
||||
expect(resolveTarget).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
});
|
||||
20
frontend/src/utils/scrollIntoViewWhenReady.ts
Normal file
20
frontend/src/utils/scrollIntoViewWhenReady.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Polls via rAF until `resolveTarget` returns an element, then scrolls it into
|
||||
* view — for nodes that mount a frame or two after the call (e.g. an optimistic
|
||||
* write). Return `null`/`undefined` while not ready; bails after `attempts`.
|
||||
*/
|
||||
export function scrollIntoViewWhenReady(
|
||||
resolveTarget: () => Element | null | undefined,
|
||||
attempts = 40,
|
||||
): void {
|
||||
const target = resolveTarget();
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return;
|
||||
}
|
||||
if (attempts > 0) {
|
||||
requestAnimationFrame(() =>
|
||||
scrollIntoViewWhenReady(resolveTarget, attempts - 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user