Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import { SidebarContent } from './SidebarContent';
import { DeveloperNoticeBanner } from './DeveloperNoticeBanner';
import { OfflineBanner } from './OfflineBanner';
import { useReconcileDeletedMonitors } from '../../hooks/useReconcileDeletedMonitors';
import { CertTrustBanner } from '../CertTrustBanner';
import { DeleteBatchBar } from '../events/DeleteBatchBar';
import { AssistantWidget } from '../assistant/AssistantWidget';
Expand Down Expand Up @@ -100,11 +101,14 @@
updateProfileSettings(currentProfile.id, { lastRoute: location.pathname });
log.app('Storing route', LogLevel.DEBUG, { route: location.pathname });
}
}, [location.pathname, currentProfile?.id, updateProfileSettings]);

Check warning on line 104 in app/src/components/layout/AppLayout.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has a missing dependency: 'location.state'. Either include it or remove the dependency array

// Apply global insomnia setting
useInsomnia({ enabled: settings.insomnia });

// Forget monitors ZoneMinder no longer has (refs #323, #324)
useReconcileDeletedMonitors();

const { isLocked, previousInsomniaState } = useKioskStore(
useShallow((state) => ({
isLocked: state.isLocked,
Expand All @@ -116,7 +120,7 @@
if (isLocked && !isCollapsed) {
setIsCollapsed(true);
}
}, [isLocked]);

Check warning on line 123 in app/src/components/layout/AppLayout.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has a missing dependency: 'isCollapsed'. Either include it or remove the dependency array

const handleKioskUnlock = useCallback(() => {
if (currentProfile) {
Expand Down
114 changes: 114 additions & 0 deletions app/src/hooks/__tests__/useReconcileDeletedMonitors.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useReconcileDeletedMonitors } from '../useReconcileDeletedMonitors';
import { useSettingsStore, DEFAULT_MONTAGE_GROUP_LAYOUT } from '../../stores/settings';
import { useDashboardStore } from '../../stores/dashboard';
import { getMonitors } from '../../api/monitors';

vi.mock('../../api/monitors', () => ({ getMonitors: vi.fn() }));
vi.mock('../useCurrentProfile', () => ({
useCurrentProfile: () => ({ currentProfile: { id: 'p1' }, settings: {} }),
}));
vi.mock('../../stores/auth', () => ({
useAuthStore: (selector: (s: { isAuthenticated: boolean }) => unknown) =>
selector({ isAuthenticated: true }),
}));

const mockGetMonitors = vi.mocked(getMonitors);

function monitorList(ids: string[]) {
return { monitors: ids.map((Id) => ({ Monitor: { Id }, Monitor_Status: undefined })) } as never;
}

function wrapper({ children }: { children: ReactNode }) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

function seedProfile() {
useSettingsStore.setState({
profileSettings: {
p1: {
excludedMonitorIds: ['1', '99'],
montageByGroup: {
all: { ...DEFAULT_MONTAGE_GROUP_LAYOUT, hiddenMonitorIds: ['99'] },
},
},
} as never,
});
useDashboardStore.setState({
widgets: {
p1: [
{
id: 'w1',
type: 'monitor',
settings: { monitorIds: ['1', '99'] },
layout: { i: 'w1', x: 0, y: 0, w: 4, h: 4 },
},
],
},
});
}

function storedState() {
return {
excluded: useSettingsStore.getState().getProfileSettings('p1').excludedMonitorIds,
montageHidden:
useSettingsStore.getState().getProfileSettings('p1').montageByGroup?.all?.hiddenMonitorIds,
widgetIds: useDashboardStore.getState().widgets.p1?.[0].settings.monitorIds,
};
}

describe('useReconcileDeletedMonitors', () => {
beforeEach(() => {
mockGetMonitors.mockReset();
seedProfile();
});

it('drops ids for monitors ZoneMinder no longer has, everywhere they are stored', async () => {
mockGetMonitors.mockResolvedValue(monitorList(['1']));

renderHook(() => useReconcileDeletedMonitors(), { wrapper });

await waitFor(() => expect(storedState().excluded).toEqual(['1']));
expect(storedState().montageHidden).toEqual([]);
expect(storedState().widgetIds).toEqual(['1']);
});

it('keeps a hidden monitor that still exists, which the ordinary monitor list omits', async () => {
// '99' is hidden, so getMonitors() without includeExcluded would not return
// it. Reconciling against that list would delete the user's own hidden
// entry, so the hook must ask for the list that includes it.
mockGetMonitors.mockResolvedValue(monitorList(['1', '99']));

renderHook(() => useReconcileDeletedMonitors(), { wrapper });

await waitFor(() => expect(mockGetMonitors).toHaveBeenCalled());
expect(mockGetMonitors).toHaveBeenCalledWith({ includeExcluded: true });
expect(storedState().excluded).toEqual(['1', '99']);
expect(storedState().widgetIds).toEqual(['1', '99']);
});

it('changes nothing when the fetch fails: an error is not proof of deletion', async () => {
mockGetMonitors.mockRejectedValue(new Error('offline'));

renderHook(() => useReconcileDeletedMonitors(), { wrapper });

await waitFor(() => expect(mockGetMonitors).toHaveBeenCalled());
expect(storedState().excluded).toEqual(['1', '99']);
expect(storedState().widgetIds).toEqual(['1', '99']);
});

it('changes nothing when the server returns no monitors at all', async () => {
mockGetMonitors.mockResolvedValue(monitorList([]));

renderHook(() => useReconcileDeletedMonitors(), { wrapper });

await waitFor(() => expect(mockGetMonitors).toHaveBeenCalled());
expect(storedState().excluded).toEqual(['1', '99']);
expect(storedState().montageHidden).toEqual(['99']);
expect(storedState().widgetIds).toEqual(['1', '99']);
});
});
77 changes: 77 additions & 0 deletions app/src/hooks/useReconcileDeletedMonitors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* useReconcileDeletedMonitors Hook
*
* Drops stored references to monitors ZoneMinder no longer has, once per
* profile per successful monitor fetch. Deleting a monitor in ZoneMinder used
* to leave it behind forever: still counted in Settings' hidden monitors with
* no way to un-hide it, and still listed in a dashboard widget's display order
* as "Monitor 12" (refs #323, #324).
*
* The list has to be the one that includes excluded monitors. The ordinary
* monitors query has already dropped the hidden ones, and reconciling the
* hidden list against a list built by removing it would delete every entry.
*
* Nothing is pruned unless the fetch succeeded and returned at least one
* monitor. An empty or failed response is indistinguishable from "every
* monitor was deleted", and acting on it would take the user's configuration
* with it.
*/

import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getMonitors } from '../api/monitors';
import { queryKeys } from '../lib/query/query-keys';
import { useAuthStore } from '../stores/auth';
import { useCurrentProfile } from './useCurrentProfile';
import { useSettingsStore } from '../stores/settings';
import { useDashboardStore } from '../stores/dashboard';
import { log, LogLevel } from '../lib/logger';
import {
pruneProfileSettingsMonitorIds,
pruneWidgetMonitorIds,
} from '../lib/monitor/prune-deleted-monitors';

export function useReconcileDeletedMonitors(): void {
const { currentProfile } = useCurrentProfile();
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const profileId = currentProfile?.id;

// Same key the hidden-monitors setting uses, so this shares its cache rather
// than adding a second full monitor fetch.
const { data, isSuccess } = useQuery({
queryKey: queryKeys.monitorsAllIncludingExcluded(profileId),
queryFn: () => getMonitors({ includeExcluded: true }),
enabled: !!profileId && isAuthenticated,
});

const monitors = data?.monitors;

useEffect(() => {
if (!profileId || !isSuccess || !monitors?.length) return;

const known = new Set(monitors.map(({ Monitor }) => Monitor.Id));

const settingsState = useSettingsStore.getState();
const patch = pruneProfileSettingsMonitorIds(
settingsState.getProfileSettings(profileId),
known
);
if (patch) {
settingsState.updateProfileSettings(profileId, patch);
log.monitor('Dropped deleted monitors from profile settings', LogLevel.INFO, {
keys: Object.keys(patch),
});
}

const dashboardState = useDashboardStore.getState();
const widgetUpdates = pruneWidgetMonitorIds(dashboardState.widgets[profileId] ?? [], known);
for (const { id, settings } of widgetUpdates) {
dashboardState.updateWidget(profileId, id, { settings });
}
if (widgetUpdates.length > 0) {
log.dashboard('Dropped deleted monitors from dashboard widgets', LogLevel.INFO, {
count: widgetUpdates.length,
});
}
}, [profileId, isSuccess, monitors]);
}
108 changes: 108 additions & 0 deletions app/src/lib/monitor/__tests__/prune-deleted-monitors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Tests for pruning references to monitors ZoneMinder no longer has
* (refs #323, #324).
*/

import { describe, it, expect } from 'vitest';
import {
pruneProfileSettingsMonitorIds,
pruneWidgetMonitorIds,
} from '../prune-deleted-monitors';
import { DEFAULT_MONTAGE_GROUP_LAYOUT } from '../../../stores/settings';
import type { DashboardWidget } from '../../../stores/dashboard';

const KNOWN = new Set(['1', '2']);

function montageBucket(overrides: Partial<typeof DEFAULT_MONTAGE_GROUP_LAYOUT>) {
return { ...DEFAULT_MONTAGE_GROUP_LAYOUT, ...overrides };
}

function monitorWidget(settings: DashboardWidget['settings']): DashboardWidget {
return {
id: 'w1',
type: 'monitor',
settings,
layout: { i: 'w1', x: 0, y: 0, w: 4, h: 4 },
};
}

describe('pruneProfileSettingsMonitorIds', () => {
it('drops a hidden monitor that no longer exists so the hidden count matches reality', () => {
const patch = pruneProfileSettingsMonitorIds({ excludedMonitorIds: ['1', '99'] }, KNOWN);

expect(patch?.excludedMonitorIds).toEqual(['1']);
});

it('keeps every id when all of them still exist', () => {
expect(pruneProfileSettingsMonitorIds({ excludedMonitorIds: ['1', '2'] }, KNOWN)).toBeNull();
});

it('drops deleted monitors from a montage group without touching the survivors', () => {
const patch = pruneProfileSettingsMonitorIds(
{
montageByGroup: {
all: montageBucket({
hiddenMonitorIds: ['2', '99'],
workingLayout: [
{ i: '1', x: 0, y: 0, w: 6, h: 6 },
{ i: '99', x: 6, y: 0, w: 6, h: 6 },
],
}),
},
},
KNOWN
);

expect(patch?.montageByGroup?.all.hiddenMonitorIds).toEqual(['2']);
expect(patch?.montageByGroup?.all.workingLayout.map((l) => l.i)).toEqual(['1']);
});

it('leaves named saved layouts alone: they are user artifacts, not live state', () => {
const saved = [
{ name: 'Front', layout: [{ i: '99', x: 0, y: 0, w: 6, h: 6 }], displayCols: 2 },
];
const patch = pruneProfileSettingsMonitorIds(
{ montageByGroup: { all: montageBucket({ hiddenMonitorIds: ['99'], savedLayouts: saved }) } },
KNOWN
);

expect(patch?.montageByGroup?.all.savedLayouts).toEqual(saved);
});

it('reports no change when a montage group holds only live monitors', () => {
const patch = pruneProfileSettingsMonitorIds(
{ montageByGroup: { all: montageBucket({ hiddenMonitorIds: ['1'] }) } },
KNOWN
);

expect(patch).toBeNull();
});
});

describe('pruneWidgetMonitorIds', () => {
it('drops a deleted monitor from a widget display order', () => {
const updates = pruneWidgetMonitorIds([monitorWidget({ monitorIds: ['1', '99', '2'] })], KNOWN);

expect(updates).toEqual([{ id: 'w1', settings: { monitorIds: ['1', '2'] } }]);
});

it('clears a single-monitor widget whose monitor is gone', () => {
const updates = pruneWidgetMonitorIds([monitorWidget({ monitorId: '99' })], KNOWN);

expect(updates[0].settings.monitorId).toBeUndefined();
});

it('leaves widgets that reference only live monitors untouched', () => {
expect(pruneWidgetMonitorIds([monitorWidget({ monitorIds: ['1'], monitorId: '2' })], KNOWN))
.toEqual([]);
});

it('keeps the rest of a widget settings object intact', () => {
const updates = pruneWidgetMonitorIds(
[monitorWidget({ monitorIds: ['99'], feedFit: 'cover', eventCount: 5 })],
KNOWN
);

expect(updates[0].settings).toEqual({ monitorIds: [], feedFit: 'cover', eventCount: 5 });
});
});
Loading
Loading