Compare commits

...

1 Commits

Author SHA1 Message Date
Ashwin Bhatkal
4e9b7aceeb feat(dashboard-v2): detect external dashboard changes on tab focus and prompt to reload 2026-07-08 09:17:42 +05:30
5 changed files with 228 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
.body {
color: var(--l2-foreground);
font-size: 14px;
line-height: 20px;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -0,0 +1,61 @@
import { RotateCcw } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import styles from './DashboardChangedDialog.module.scss';
interface DashboardChangedDialogProps {
open: boolean;
onReload: () => void;
onDismiss: () => void;
}
function DashboardChangedDialog({
open,
onReload,
onDismiss,
}: DashboardChangedDialogProps): JSX.Element {
const footer = (
<div className={styles.footer}>
<Button
variant="solid"
color="secondary"
onClick={onDismiss}
testId="dashboard-changed-dismiss"
>
Dismiss
</Button>
<Button
variant="solid"
color="primary"
prefix={<RotateCcw size={12} />}
onClick={onReload}
testId="dashboard-changed-reload"
>
Reload
</Button>
</div>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onDismiss();
}
}}
title="Dashboard updated elsewhere"
width="narrow"
showCloseButton={false}
footer={footer}
>
<div className={styles.body}>
This dashboard was changed in another tab or by another user. Reload to see
the latest version.
</div>
</DialogWrapper>
);
}
export default DashboardChangedDialog;

View File

@@ -0,0 +1,74 @@
import { act, renderHook } from '@testing-library/react';
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 } } });
}
describe('useDashboardStaleCheck', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseIsMutating.mockReturnValue(0);
});
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()),
);
expect(result.current.showPrompt).toBe(true);
});
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()),
);
expect(result.current.showPrompt).toBe(false);
});
it('does not prompt while a mutation is in flight (optimistic save)', () => {
mockUseIsMutating.mockReturnValue(1);
setServerUpdatedAt('2026-07-08T10:00:00Z');
const { result } = renderHook(() =>
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
);
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()),
);
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');
const refetch = jest.fn();
const { result } = renderHook(() =>
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', refetch),
);
act(() => result.current.dismiss());
expect(result.current.showPrompt).toBe(false);
act(() => result.current.reload());
expect(refetch).toHaveBeenCalledTimes(1);
expect(result.current.showPrompt).toBe(true);
});
});

View File

@@ -0,0 +1,69 @@
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';
interface UseDashboardStaleCheck {
// Server copy has a newer updatedAt than the loaded one, 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.
*/
export function useDashboardStaleCheck(
dashboardId: string,
loadedUpdatedAt: string | undefined,
refetch: () => void,
): UseDashboardStaleCheck {
const isMutating = useIsMutating() > 0;
const queryClient = useQueryClient();
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 }),
),
},
);
const serverUpdatedAt = data?.data?.updatedAt;
const changed =
!isMutating &&
!!serverUpdatedAt &&
!!loadedUpdatedAt &&
serverUpdatedAt !== loadedUpdatedAt;
const dismiss = useCallback(
(): void => setDismissedAt(serverUpdatedAt ?? null),
[serverUpdatedAt],
);
const reload = useCallback((): void => {
refetch();
setDismissedAt(null);
}, [refetch]);
return {
showPrompt: changed && serverUpdatedAt !== dismissedAt,
reload,
dismiss,
};
}

View File

@@ -12,6 +12,8 @@ import { useSyncVariablesForSuggestions } from './hooks/useSyncVariablesForSugge
import { useDashboardStore } from './store/useDashboardStore';
import styles from './DashboardContainer.module.scss';
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
import DashboardChangedDialog from './components/DashboardChangedDialog/DashboardChangedDialog';
import { useDashboardStaleCheck } from './hooks/useDashboardStaleCheck';
import { Base64Icons } from './DashboardSettings/Overview/utils';
interface DashboardContainerProps {
@@ -56,6 +58,12 @@ function DashboardContainer({
// suggests them ($variable) in the panel editor and dashboards-page builder.
useSyncVariablesForSuggestions(dashboard);
const staleCheck = useDashboardStaleCheck(
dashboard.id,
dashboard.updatedAt,
refetch,
);
return (
<FullScreen handle={fullScreenHandle}>
<div className={styles.container}>
@@ -66,6 +74,11 @@ function DashboardContainer({
refetch={refetch}
/>
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
<DashboardChangedDialog
open={staleCheck.showPrompt}
onReload={staleCheck.reload}
onDismiss={staleCheck.dismiss}
/>
</div>
</FullScreen>
);