mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-08 07:30:40 +01:00
Compare commits
1 Commits
main
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e9b7aceeb |
@@ -0,0 +1,11 @@
|
||||
.body {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user