Compare commits

..

4 Commits

Author SHA1 Message Date
Ashwin Bhatkal
c84245d381 feat(frontend): move open tabs to the latest deployed build 2026-11-12 23:27:38 +05:30
Ashwin Bhatkal
e7d10078de refactor(frontend): share the unload prompt and count unsaved changes 2026-11-12 23:27:38 +05:30
Ashwin Bhatkal
6493e2a503 fix(web): return 404 for missing frontend assets 2026-11-12 23:27:38 +05:30
Ashwin Bhatkal
ea05b1ee0e feat(apiserver): send the server version on every response 2026-11-12 23:27:38 +05:30
24 changed files with 513 additions and 18 deletions

View File

@@ -3,6 +3,8 @@ import { QueryClient } from 'react-query';
import getLocalStorageApi from 'api/browser/localstorage/get';
import post from 'api/v2/sessions/rotate/post';
import afterLogin from 'AppRoutes/utils';
import { SERVER_VERSION_HEADER } from 'components/NewVersionPrompt/constants';
import { reportServerVersion } from 'components/NewVersionPrompt/useAppVersionStore';
import axios, {
AxiosError,
AxiosResponse,
@@ -48,6 +50,11 @@ export const interceptorsResponse = (
}
}
const serverVersion = value.headers?.[SERVER_VERSION_HEADER];
if (typeof serverVersion === 'string') {
reportServerVersion(serverVersion);
}
return Promise.resolve(value);
};

View File

@@ -0,0 +1,42 @@
import { useEffect, useRef } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useUnsavedChangesStore } from 'hooks/useUnsavedChangesStore';
import { reloadApp } from 'utils/appReload';
import { NEW_VERSION_TOAST_ID } from './constants';
import { useAppReloadNotice } from './useAppReloadNotice';
import { useAppVersionStore } from './useAppVersionStore';
import { useCheckVersionOnTabReturn } from './useCheckVersionOnTabReturn';
import { useLoadNewVersionOnNavigation } from './useLoadNewVersionOnNavigation';
function NewVersionPrompt(): null {
const isNewVersionAvailable = useAppVersionStore(
(state) => state.isNewVersionAvailable,
);
const hasUnsavedChanges = useUnsavedChangesStore((state) => state.count > 0);
const hasPrompted = useRef(false);
useAppReloadNotice();
useCheckVersionOnTabReturn();
useLoadNewVersionOnNavigation(isNewVersionAvailable && !hasUnsavedChanges);
useEffect(() => {
if (!isNewVersionAvailable || hasUnsavedChanges || hasPrompted.current) {
return;
}
hasPrompted.current = true;
toast.info('A new version of SigNoz is available', {
id: NEW_VERSION_TOAST_ID,
description: 'It loads when you open another page.',
duration: Infinity,
action: {
label: 'Refresh',
onClick: (): void => reloadApp({ reason: 'refresh-clicked', detail: '' }),
},
});
}, [isNewVersionAvailable, hasUnsavedChanges]);
return null;
}
export default NewVersionPrompt;

View File

@@ -0,0 +1,153 @@
import { toast } from '@signozhq/ui/sonner';
import {
holdUnsavedChanges,
useUnsavedChangesStore,
} from 'hooks/useUnsavedChangesStore';
import { getCurrentLocation, navigate } from 'lib/router/navigation';
import { rest, server } from 'mocks-server/server';
import { act, fireEvent, render, waitFor } from 'tests/test-utils';
import { Info } from 'types/api/v1/version/get';
import { consumeAppReload, markAppReload, reloadApp } from 'utils/appReload';
import { NEW_VERSION_TOAST_ID, SERVER_VERSION_HEADER } from '../constants';
import NewVersionPrompt from '../NewVersionPrompt';
import { reportServerVersion, useAppVersionStore } from '../useAppVersionStore';
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { info: jest.fn(), loading: jest.fn() },
}));
jest.mock('utils/appReload', () => ({
...jest.requireActual('utils/appReload'),
reloadApp: jest.fn(),
}));
const VERSION_ENDPOINT = '*/api/v1/version';
const BUNDLE_VERSION = 'v0.100.0';
const NEW_VERSION = 'v0.101.0';
const expectRefreshPrompt = (): void => {
expect(toast.info).toHaveBeenCalledTimes(1);
expect(toast.info).toHaveBeenCalledWith(
'A new version of SigNoz is available',
expect.objectContaining({
id: NEW_VERSION_TOAST_ID,
description: 'It loads when you open another page.',
duration: Infinity,
action: expect.objectContaining({ label: 'Refresh' }),
}),
);
};
describe('NewVersionPrompt', () => {
const originalVersion = process.env.VERSION;
let servedVersion: string;
let versionRequests: number;
beforeEach(() => {
jest.clearAllMocks();
process.env.VERSION = BUNDLE_VERSION;
useAppVersionStore.setState({ isNewVersionAvailable: false });
useUnsavedChangesStore.setState({ count: 0 });
consumeAppReload();
servedVersion = BUNDLE_VERSION;
versionRequests = 0;
server.use(
rest.get(VERSION_ENDPOINT, (_req, res, ctx) => {
versionRequests += 1;
const body: Info = { version: '', ee: 'Y', setupCompleted: true };
return res(ctx.set(SERVER_VERSION_HEADER, servedVersion), ctx.json(body));
}),
);
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
process.env.VERSION = originalVersion;
});
it('prompts to refresh once the server runs a different build than the tab', async () => {
render(<NewVersionPrompt />);
await waitFor(() => expect(versionRequests).toBe(1));
expect(toast.info).not.toHaveBeenCalled();
servedVersion = NEW_VERSION;
fireEvent.focus(window);
await waitFor(expectRefreshPrompt);
});
it('stays quiet while the server runs the same build or either version is unknown', () => {
reportServerVersion(BUNDLE_VERSION);
reportServerVersion('');
process.env.VERSION = '';
reportServerVersion(NEW_VERSION);
expect(useAppVersionStore.getState().isNewVersionAvailable).toBe(false);
});
it('waits until unsaved changes are saved or discarded', () => {
render(<NewVersionPrompt />);
let releaseUnsavedChanges = (): void => undefined;
act(() => {
releaseUnsavedChanges = holdUnsavedChanges();
reportServerVersion(NEW_VERSION);
});
expect(toast.info).not.toHaveBeenCalled();
act(() => releaseUnsavedChanges());
expectRefreshPrompt();
});
it('opens the next page with a full page load once a new version is out', () => {
render(<NewVersionPrompt />, undefined, { initialRoute: '/home' });
act(() => reportServerVersion(NEW_VERSION));
act(() => navigate('/home?search=cpu'));
expect(reloadApp).not.toHaveBeenCalled();
expect(getCurrentLocation().search).toBe('?search=cpu');
act(() => navigate('/dashboard'));
expect(toast.loading).toHaveBeenCalledWith(
'Loading the latest version of SigNoz',
expect.objectContaining({ id: NEW_VERSION_TOAST_ID }),
);
expect(reloadApp).toHaveBeenCalledWith(
{ reason: 'navigation', detail: '/dashboard' },
'/dashboard',
);
expect(getCurrentLocation().pathname).toBe('/home');
});
it('says SigNoz was updated when the reload landed on a new build', () => {
process.env.VERSION = 'v0.099.0';
markAppReload({ reason: 'navigation', detail: '/dashboard' });
process.env.VERSION = BUNDLE_VERSION;
render(<NewVersionPrompt />);
expect(toast.info).toHaveBeenCalledWith('SigNoz was updated', {
description: 'The page reloaded to load the latest version.',
});
expect(consumeAppReload()).toBeNull();
});
it('says part of the page failed to load when the reload kept the same build', () => {
markAppReload({
reason: 'chunk-load-failed',
detail: 'Failed to fetch dynamically imported module: /assets/Home.js',
});
render(<NewVersionPrompt />);
expect(toast.info).toHaveBeenCalledWith('The page reloaded', {
description: 'Part of the page failed to load.',
});
});
});

View File

@@ -0,0 +1,3 @@
export const SERVER_VERSION_HEADER = 'x-signoz-version';
export const NEW_VERSION_TOAST_ID = 'new-version';

View File

@@ -0,0 +1,36 @@
import { useEffect } from 'react';
import * as Sentry from '@sentry/react';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import { consumeAppReload } from 'utils/appReload';
export function useAppReloadNotice(): void {
useEffect(() => {
const reload = consumeAppReload();
if (!reload) {
return;
}
const toVersion = process.env.VERSION;
const isUpdated = reload.fromVersion !== toVersion;
const data = { ...reload, toVersion };
Sentry.addBreadcrumb({
category: 'app.reload',
message: isUpdated
? 'Page reloaded to load the latest version'
: 'Page reloaded',
data,
});
void logEvent('App: Reloaded', data);
if (isUpdated) {
toast.info('SigNoz was updated', {
description: 'The page reloaded to load the latest version.',
});
} else {
toast.info('The page reloaded', {
description: 'Part of the page failed to load.',
});
}
}, []);
}

View File

@@ -0,0 +1,16 @@
import { create } from 'zustand';
interface AppVersionState {
isNewVersionAvailable: boolean;
}
export const useAppVersionStore = create<AppVersionState>()(() => ({
isNewVersionAvailable: false,
}));
export const reportServerVersion = (serverVersion: string): void => {
const bundleVersion = process.env.VERSION;
if (bundleVersion && serverVersion && serverVersion !== bundleVersion) {
useAppVersionStore.setState({ isNewVersionAvailable: true });
}
};

View File

@@ -0,0 +1,13 @@
import { useQuery } from 'react-query';
import getUserVersion from 'api/v1/version/get';
import { useAppContext } from 'providers/App/App';
export function useCheckVersionOnTabReturn(): void {
const { isLoggedIn, user } = useAppContext();
useQuery({
queryFn: getUserVersion,
queryKey: ['getUserVersion', user?.accessJwt],
enabled: isLoggedIn,
refetchOnWindowFocus: true,
});
}

View File

@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { toast } from '@signozhq/ui/sonner';
import {
blockNavigation,
getCurrentLocation,
getHref,
} from 'lib/router/navigation';
import { reloadApp } from 'utils/appReload';
import { NEW_VERSION_TOAST_ID } from './constants';
export function useLoadNewVersionOnNavigation(enabled: boolean): void {
useEffect(() => {
if (!enabled) {
return undefined;
}
return blockNavigation(({ location, retry }) => {
if (location.pathname === getCurrentLocation().pathname) {
retry();
return;
}
// sonner merges an update into the toast with the same id, so clear the prompt's description and action.
toast.loading('Loading the latest version of SigNoz', {
id: NEW_VERSION_TOAST_ID,
description: undefined,
action: undefined,
});
reloadApp(
{ reason: 'navigation', detail: location.pathname },
getHref(location),
);
});
}, [enabled]);
}

View File

@@ -1,4 +1,5 @@
export enum SESSIONSTORAGE {
RETRY_LAZY_REFRESHED = 'retry-lazy-refreshed',
VIEW_PANEL_HANDOFF = 'view-panel-handoff',
APP_RELOAD = 'app-reload',
}

View File

@@ -28,6 +28,7 @@ import getUserLatestVersion from 'api/v1/version/getLatestVersion';
import { AxiosError } from 'axios';
import cx from 'classnames';
import ChangelogModal from 'components/ChangelogModal/ChangelogModal';
import NewVersionPrompt from 'components/NewVersionPrompt/NewVersionPrompt';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
@@ -846,6 +847,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
<Toaster
offset={{ bottom: 'calc(var(--bottom-strip-height, 0px) + 24px)' }}
/>
<NewVersionPrompt />
</Layout>
</TooltipProvider>
);

View File

@@ -1,6 +1,7 @@
import { renderHook } from '@testing-library/react';
import { renderHook } from 'tests/test-utils';
import { useUnloadPrompt } from '../useUnloadPrompt';
import { useUnsavedChangesStore } from '../useUnsavedChangesStore';
function fireBeforeUnload(): Event {
const event = new Event('beforeunload', { cancelable: true });
@@ -29,4 +30,14 @@ describe('useUnloadPrompt', () => {
expect(fireBeforeUnload().defaultPrevented).toBe(false);
});
it('marks the page as having unsaved changes until they are saved or discarded', () => {
const { rerender } = renderHook(({ enabled }) => useUnloadPrompt(enabled), {
initialProps: { enabled: true },
});
expect(useUnsavedChangesStore.getState().count).toBe(1);
rerender({ enabled: false });
expect(useUnsavedChangesStore.getState().count).toBe(0);
});
});

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useUnloadPrompt } from 'hooks/useUnloadPrompt';
import { blockNavigation } from 'lib/router/navigation';
import type {
AppLocation,
@@ -88,21 +89,7 @@ export function useNavigationBlocker(
};
}, [shouldBlock]);
useEffect(() => {
if (!shouldBlock) {
return;
}
const handleBeforeUnload = (event: BeforeUnloadEvent): void => {
event.preventDefault();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return (): void => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [shouldBlock]);
useUnloadPrompt(shouldBlock);
const confirmNavigation = useCallback((): void => {
if (!blockedNavigation) {

View File

@@ -1,6 +1,10 @@
import { useEffect } from 'react';
import { holdUnsavedChanges } from 'hooks/useUnsavedChangesStore';
/** While `enabled`, a refresh or tab close asks the browser to confirm leaving. */
/**
* While `enabled`, a refresh or tab close asks the browser to confirm leaving,
* and the new-version prompt waits.
*/
export function useUnloadPrompt(enabled: boolean): void {
useEffect(() => {
if (!enabled) {
@@ -10,8 +14,10 @@ export function useUnloadPrompt(enabled: boolean): void {
event.preventDefault();
};
window.addEventListener('beforeunload', handleBeforeUnload);
const releaseUnsavedChanges = holdUnsavedChanges();
return (): void => {
window.removeEventListener('beforeunload', handleBeforeUnload);
releaseUnsavedChanges();
};
}, [enabled]);
}

View File

@@ -0,0 +1,15 @@
import { create } from 'zustand';
interface UnsavedChangesState {
count: number;
}
export const useUnsavedChangesStore = create<UnsavedChangesState>()(() => ({
count: 0,
}));
export const holdUnsavedChanges = (): (() => void) => {
useUnsavedChangesStore.setState((state) => ({ count: state.count + 1 }));
return (): void =>
useUnsavedChangesStore.setState((state) => ({ count: state.count - 1 }));
};

View File

@@ -51,6 +51,10 @@ export function getCurrentLocation(): AppLocation {
return toAppLocation(history.location);
}
export function getHref(location: AppLocation): string {
return history.createHref(location);
}
export function blockNavigation(blocker: NavigationBlocker): () => void {
return history.block((location, action) => {
const appLocation = toAppLocation(location);

View File

@@ -12,7 +12,7 @@ import { useConfirmableAction } from 'hooks/useConfirmableAction';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { useUnloadPrompt } from '../hooks/useUnloadPrompt';
import { useUnloadPrompt } from 'hooks/useUnloadPrompt';
import styles from './Header.module.scss';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';

View File

@@ -0,0 +1,47 @@
import setSessionStorageApi from 'api/browser/sessionstorage/set';
import { SESSIONSTORAGE } from 'constants/sessionStorage';
import { consumeAppReload } from 'utils/appReload';
import { lazyRetry } from 'utils/lazyWithRetries';
const chunkError = new Error(
'Failed to fetch dynamically imported module: /assets/DashboardPage-DdQ20L2t.js',
);
const settleWithinATick = (promise: Promise<unknown>): Promise<string> =>
Promise.race([
promise.then(
() => 'resolved',
() => 'rejected',
),
new Promise<string>((resolve) => {
setTimeout(() => resolve('pending'), 0);
}),
]);
describe('lazyRetry', () => {
beforeEach(() => {
setSessionStorageApi(SESSIONSTORAGE.RETRY_LAZY_REFRESHED, 'false');
consumeAppReload();
});
it('keeps loading and records why while it reloads after a chunk fails', async () => {
const outcome = await settleWithinATick(
lazyRetry(() => Promise.reject(chunkError)),
);
expect(outcome).toBe('pending');
expect(consumeAppReload()).toStrictEqual({
reason: 'chunk-load-failed',
detail: chunkError.message,
});
});
it('fails without reloading when the chunk is still missing after the reload', async () => {
setSessionStorageApi(SESSIONSTORAGE.RETRY_LAZY_REFRESHED, 'true');
await expect(lazyRetry(() => Promise.reject(chunkError))).rejects.toBe(
chunkError,
);
expect(consumeAppReload()).toBeNull();
});
});

View File

@@ -0,0 +1,46 @@
import getSessionStorageApi from 'api/browser/sessionstorage/get';
import removeSessionStorageApi from 'api/browser/sessionstorage/remove';
import setSessionStorageApi from 'api/browser/sessionstorage/set';
import { SESSIONSTORAGE } from 'constants/sessionStorage';
export interface AppReload {
reason: 'refresh-clicked' | 'navigation' | 'chunk-load-failed';
detail: string;
}
export interface RecordedAppReload extends AppReload {
fromVersion?: string;
}
export function markAppReload(reload: AppReload): boolean {
const recorded: RecordedAppReload = {
...reload,
fromVersion: process.env.VERSION,
};
return setSessionStorageApi(
SESSIONSTORAGE.APP_RELOAD,
JSON.stringify(recorded),
);
}
export function consumeAppReload(): RecordedAppReload | null {
const stored = getSessionStorageApi(SESSIONSTORAGE.APP_RELOAD);
if (!stored) {
return null;
}
removeSessionStorageApi(SESSIONSTORAGE.APP_RELOAD);
try {
return JSON.parse(stored) as RecordedAppReload;
} catch {
return null;
}
}
export function reloadApp(reload: AppReload, href?: string): void {
markAppReload(reload);
if (href) {
window.location.assign(href);
} else {
window.location.reload();
}
}

View File

@@ -1,6 +1,7 @@
import getSessionStorageApi from 'api/browser/sessionstorage/get';
import setSessionStorageApi from 'api/browser/sessionstorage/set';
import { SESSIONSTORAGE } from 'constants/sessionStorage';
import { markAppReload } from 'utils/appReload';
type ComponentImport = () => Promise<any>;
@@ -24,7 +25,11 @@ export const lazyRetry = (componentImport: ComponentImport): Promise<any> =>
!hasRefreshed &&
setSessionStorageApi(SESSIONSTORAGE.RETRY_LAZY_REFRESHED, 'true')
) {
markAppReload({ reason: 'chunk-load-failed', detail: error.message });
window.location.reload();
// Left pending, so Suspense keeps its loader up until the reload lands
// instead of the error boundary flashing its fallback.
return;
}
reject(error);

View File

@@ -46,6 +46,7 @@ import (
"github.com/SigNoz/signoz/pkg/subscription"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/web"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/gorilla/mux"
@@ -322,6 +323,7 @@ func newProvider(
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
router.Use(middleware.NewRecovery(settings.Logger()).Wrap)
router.Use(middleware.NewVersion(version.Info.Version()).Wrap)
router.Use(middleware.NewOtel("apiserver", providerSettings.MeterProvider, providerSettings.TracerProvider).Wrap)
router.Use(middleware.NewIdentN(identNResolver, sharder, settings.Logger()).Wrap)
router.Use(middleware.NewTimeout(settings.Logger(),

View File

@@ -16,6 +16,7 @@ func NewCors() *Cors {
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
ExposedHeaders: []string{versionHeader},
}),
}
}

View File

@@ -0,0 +1,22 @@
package middleware
import (
"net/http"
)
const versionHeader = "X-Signoz-Version"
type Version struct {
version string
}
func NewVersion(version string) *Version {
return &Version{version: version}
}
func (middleware *Version) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
rw.Header().Set(versionHeader, middleware.version)
next.ServeHTTP(rw, req)
})
}

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
@@ -16,6 +17,9 @@ import (
"github.com/gorilla/mux"
)
// Vite's build.assetsDir.
const assetsPathPrefix = "/assets/"
type provider struct {
config web.Config
indexContents []byte
@@ -83,6 +87,11 @@ func (provider *provider) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// check whether a file exists or is a directory at the given path
fi, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) && strings.HasPrefix(req.URL.Path, assetsPathPrefix) {
http.NotFound(rw, req)
return
}
// if the file doesn't exist, serve index.html
if os.IsNotExist(err) {
provider.serveIndex(rw)

View File

@@ -241,6 +241,39 @@ func TestServeInvalidTemplateIndex(t *testing.T) {
}
}
func TestServeMissingPath(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
path string
statusCode int
}{
{
name: "Asset_NotFound",
path: "/assets/DashboardPage-DdQ20L2t.js",
statusCode: http.StatusNotFound,
},
{
name: "AppRoute_ServesIndex",
path: "/dashboard/0198a2c4",
statusCode: http.StatusOK,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
base := startServer(t, web.Config{Index: "valid_template.html", Directory: "testdata"}, global.Config{})
res, err := http.DefaultClient.Get(base + testCase.path)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Equal(t, testCase.statusCode, res.StatusCode)
})
}
}
func TestServeStaticFilesUnchanged(t *testing.T) {
t.Parallel()