mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-10 00:20:40 +01:00
Compare commits
5 Commits
worktree-j
...
fixes/dash
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a18b8e330f | ||
|
|
72e6ec7386 | ||
|
|
9018a7cf0b | ||
|
|
f656a571df | ||
|
|
6d6e939363 |
@@ -1,3 +1,4 @@
|
||||
export enum SESSIONSTORAGE {
|
||||
RETRY_LAZY_REFRESHED = 'retry-lazy-refreshed',
|
||||
VIEW_PANEL_HANDOFF = 'view-panel-handoff',
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
clearViewPanelHandoff,
|
||||
readViewPanelHandoff,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/ViewPanelModal/viewPanelHandoffStore';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useSwitchToViewMode } from '../useSwitchToViewMode';
|
||||
@@ -18,11 +23,16 @@ jest.mock('hooks/useUrlQuery', () => ({
|
||||
}));
|
||||
|
||||
const query = { queryType: 'builder' } as unknown as Query;
|
||||
const spec = {
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel' },
|
||||
display: { name: 'CPU' },
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
describe('useSwitchToViewMode', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSearch = '';
|
||||
clearViewPanelHandoff();
|
||||
});
|
||||
|
||||
function invoke(): void {
|
||||
@@ -32,6 +42,7 @@ describe('useSwitchToViewMode', () => {
|
||||
panelId: 'panel-1',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
query,
|
||||
spec,
|
||||
}),
|
||||
);
|
||||
result.current();
|
||||
@@ -52,6 +63,21 @@ describe('useSwitchToViewMode', () => {
|
||||
).toStrictEqual(query);
|
||||
});
|
||||
|
||||
it('stashes the live draft spec in the sessionStorage handoff, not the URL', () => {
|
||||
invoke();
|
||||
|
||||
expect(readViewPanelHandoff('dash-1', 'panel-1')).toStrictEqual(spec);
|
||||
// The spec must not bloat the URL — the config-only display name never leaks into it.
|
||||
expect(mockSafeNavigate.mock.calls[0][0]).not.toContain('CPU');
|
||||
});
|
||||
|
||||
it('scopes the handoff to the exact dashboard + panel', () => {
|
||||
invoke();
|
||||
|
||||
expect(readViewPanelHandoff('dash-1', 'other-panel')).toBeNull();
|
||||
expect(readViewPanelHandoff('other-dash', 'panel-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('carries dashboard variables through and drops other editor URL state', () => {
|
||||
mockSearch = 'variables=%7B%22a%22%3A1%7D&compositeQuery=stale';
|
||||
invoke();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -7,27 +8,35 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { writeViewPanelHandoff } from '../../PanelsAndSectionsLayout/Panel/ViewPanelModal/viewPanelHandoffStore';
|
||||
|
||||
interface UseSwitchToViewModeArgs {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panelType: PANEL_TYPES;
|
||||
query: Query;
|
||||
/** Live (un-saved) draft spec — the query rides in the URL, the rest via the handoff. */
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback that leaves the editor for the dashboard with this panel expanded in the
|
||||
* View modal, seeded with the live (un-saved) query — V1's "Switch to View Mode".
|
||||
* Leaves the editor for the dashboard with this panel expanded in the View modal, seeded with
|
||||
* the live (un-saved) query + config — V1's "Switch to View Mode". The query rides in the URL
|
||||
* (`compositeQuery`); the rest of the spec rides in a tab-scoped sessionStorage handoff.
|
||||
*/
|
||||
export function useSwitchToViewMode({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panelType,
|
||||
query,
|
||||
spec,
|
||||
}: UseSwitchToViewModeArgs): () => void {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
return useCallback((): void => {
|
||||
writeViewPanelHandoff({ dashboardId, panelId, spec });
|
||||
|
||||
const params = new URLSearchParams();
|
||||
const variables = urlQuery.get(QueryParams.variables);
|
||||
if (variables) {
|
||||
@@ -42,5 +51,5 @@ export function useSwitchToViewMode({
|
||||
safeNavigate(
|
||||
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}?${params.toString()}`,
|
||||
);
|
||||
}, [safeNavigate, urlQuery, dashboardId, panelId, panelType, query]);
|
||||
}, [safeNavigate, urlQuery, dashboardId, panelId, panelType, query, spec]);
|
||||
}
|
||||
|
||||
@@ -204,6 +204,7 @@ function PanelEditorContainer({
|
||||
panelId,
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
|
||||
query: currentQuery,
|
||||
spec: draft.spec,
|
||||
});
|
||||
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
|
||||
@@ -106,8 +106,8 @@ function ViewPanelModalHeader({
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onRefresh}
|
||||
disabled={isFetching}
|
||||
aria-label="Refresh"
|
||||
|
||||
@@ -23,8 +23,11 @@ import {
|
||||
type PanelQueryTimeOverride,
|
||||
type UsePanelQueryResult,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { readViewPanelHandoff } from './viewPanelHandoffStore';
|
||||
|
||||
interface UseViewPanelModeArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
@@ -77,25 +80,33 @@ export function useViewPanelMode({
|
||||
}: UseViewPanelModeArgs): UseViewPanelModeReturn {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
// Seed the draft from the URL (`compositeQuery` + `graphType`) when present, else the saved
|
||||
// panel — mount-only, so a refresh re-seeds from the URL and in-modal edits survive (V1 parity).
|
||||
const urlQuery = useGetCompositeQueryParam();
|
||||
// Config edits from the editor's "Switch to View Mode" arrive via the handoff; the query
|
||||
// still comes from the URL. Falls back to the saved panel for a plain grid "View".
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const baseSpec = useMemo<DashboardtypesPanelSpecDTO>(
|
||||
() => readViewPanelHandoff(dashboardId, panelId) ?? panel.spec,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed
|
||||
[],
|
||||
);
|
||||
|
||||
// Mount-only so a refresh re-seeds and in-modal edits survive (V1 parity).
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const urlGraphType = useUrlQuery().get(
|
||||
QueryParams.graphType,
|
||||
) as PANEL_TYPES | null;
|
||||
const initialPanel = useMemo<DashboardtypesPanelDTO>(
|
||||
() =>
|
||||
urlQuery
|
||||
compositeQuery
|
||||
? {
|
||||
...panel,
|
||||
spec: buildViewPanelSpec({
|
||||
spec: panel.spec,
|
||||
query: urlQuery,
|
||||
spec: baseSpec,
|
||||
query: compositeQuery,
|
||||
panelType:
|
||||
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
|
||||
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[baseSpec.plugin.kind],
|
||||
}),
|
||||
}
|
||||
: panel,
|
||||
: { ...panel, spec: baseSpec },
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed from the URL
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import getSessionStorage from 'api/browser/sessionstorage/get';
|
||||
import removeSessionStorage from 'api/browser/sessionstorage/remove';
|
||||
import setSessionStorage from 'api/browser/sessionstorage/set';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { SESSIONSTORAGE } from 'constants/sessionStorage';
|
||||
|
||||
interface ViewPanelHandoff {
|
||||
/** Correlator: the read returns the spec only for this exact dashboard + panel. */
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab-scoped handoff of the editor's un-saved draft spec to the View modal, so "Switch to View
|
||||
* Mode" carries config edits — not just the query, which stays in the URL. sessionStorage keeps
|
||||
* the link small yet survives a refresh, and clears the edits when the tab closes.
|
||||
*/
|
||||
export function writeViewPanelHandoff(handoff: ViewPanelHandoff): void {
|
||||
setSessionStorage(SESSIONSTORAGE.VIEW_PANEL_HANDOFF, JSON.stringify(handoff));
|
||||
}
|
||||
|
||||
export function readViewPanelHandoff(
|
||||
dashboardId: string,
|
||||
panelId: string,
|
||||
): DashboardtypesPanelSpecDTO | null {
|
||||
const raw = getSessionStorage(SESSIONSTORAGE.VIEW_PANEL_HANDOFF);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const handoff = JSON.parse(raw) as ViewPanelHandoff;
|
||||
return handoff.dashboardId === dashboardId && handoff.panelId === panelId
|
||||
? handoff.spec
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearViewPanelHandoff(): void {
|
||||
removeSessionStorage(SESSIONSTORAGE.VIEW_PANEL_HANDOFF);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { clearViewPanelHandoff } from '../ViewPanelModal/viewPanelHandoffStore';
|
||||
|
||||
export interface UseViewPanelApi {
|
||||
/** Panel id currently expanded in the View modal; null when none is open. */
|
||||
expandedPanelId: string | null;
|
||||
@@ -41,10 +43,11 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
// Copy before mutating: useUrlQuery returns a memoized instance.
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.set(QueryParams.expandedWidgetId, panelId);
|
||||
// Drop any leftover in-modal query/kind so a plain View opens on the saved
|
||||
// panel, not a stale URL query the modal would otherwise hydrate from.
|
||||
// Drop leftover in-modal query/kind + the editor's handoff so a plain View opens
|
||||
// on the saved panel, not stale state the modal would otherwise hydrate from.
|
||||
next.delete(QueryParams.compositeQuery);
|
||||
next.delete(QueryParams.graphType);
|
||||
clearViewPanelHandoff();
|
||||
safeNavigate(`${pathname}?${next.toString()}`);
|
||||
},
|
||||
[pathname, safeNavigate, urlQuery],
|
||||
@@ -55,6 +58,8 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.set(QueryParams.expandedWidgetId, panelId);
|
||||
next.set(QueryParams.graphType, panelType);
|
||||
// A grid drilldown opens on the saved panel, never a stale editor handoff.
|
||||
clearViewPanelHandoff();
|
||||
// Same encoding the query builder uses (see `useGetCompositeQueryParam`): the URL
|
||||
// value is `encodeURIComponent(JSON.stringify(query))`, decoded once on read.
|
||||
next.set(
|
||||
@@ -73,6 +78,7 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
// (the in-modal query builder writes compositeQuery, V1 parity).
|
||||
next.delete(QueryParams.compositeQuery);
|
||||
next.delete(QueryParams.graphType);
|
||||
clearViewPanelHandoff();
|
||||
const search = next.toString();
|
||||
safeNavigate(search ? `${pathname}?${search}` : pathname);
|
||||
}, [pathname, safeNavigate, urlQuery]);
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
.grid {
|
||||
// Mount-only (SectionGrid.tsx `entering`): kill the entrance unfold that trips the
|
||||
// lazy-load observer. Dropping the class restores RGL's transition for drag/resize.
|
||||
&.entering
|
||||
:global(.react-grid-item.cssTransforms:not(.react-grid-placeholder)) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
// Override react-grid-layout's default red drag/resize placeholder with the
|
||||
// SigNoz brand blue.
|
||||
:global(.react-grid-item.react-grid-placeholder) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
@@ -22,6 +23,15 @@ function SectionGrid({
|
||||
sections,
|
||||
}: SectionGridProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
|
||||
// Skip RGL's mount unfold (it piles panels into the viewport and trips the lazy-load
|
||||
// observer) by suppressing the item transition for the first frame only.
|
||||
const [entered, setEntered] = useState(false);
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(() => setEntered(true));
|
||||
return (): void => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
const rglLayout = useMemo<Layout[]>(
|
||||
() =>
|
||||
items.map((item) => ({
|
||||
@@ -38,7 +48,7 @@ function SectionGrid({
|
||||
|
||||
return (
|
||||
<ResponsiveGridLayout
|
||||
className={styles.grid}
|
||||
className={cx(styles.grid, !entered && styles.entering)}
|
||||
cols={12}
|
||||
rowHeight={45}
|
||||
autoSize
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('useScrollIntoView', () => {
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
block: 'center',
|
||||
});
|
||||
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
|
||||
});
|
||||
@@ -38,7 +38,7 @@ describe('useScrollIntoView', () => {
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
block: 'center',
|
||||
});
|
||||
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
|
||||
export function useScrollIntoView(
|
||||
id: string,
|
||||
ref: RefObject<HTMLElement>,
|
||||
block: ScrollLogicalPosition = 'start',
|
||||
block: ScrollLogicalPosition = 'center',
|
||||
): void {
|
||||
const scrollTargetId = useScrollIntoViewStore((s) => s.scrollTargetId);
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
|
||||
@@ -107,6 +107,9 @@ export function ContextMenu({
|
||||
}
|
||||
}}
|
||||
trigger="click"
|
||||
// Anchor to body (like the backdrop), not the host container: a modal's
|
||||
// transformed dialog would break `position: fixed` and trap the menu below it.
|
||||
getPopupContainer={(): HTMLElement => document.body}
|
||||
overlayStyle={{
|
||||
position: 'fixed',
|
||||
left: position.left,
|
||||
|
||||
@@ -77,6 +77,11 @@
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
// Body-portaled overlay: stay clickable when a modal sets `body { pointer-events: none }`.
|
||||
.context-menu {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.context-menu .ant-popover-inner {
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
|
||||
Reference in New Issue
Block a user