fix: recreate watcher on path change (#1203)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Bug Fixes**
- Improved the notifications system by refreshing the monitoring process
only when configuration changes occur, leading to a more reliable
experience.

- **Chores**
- Updated internal synchronization timestamps in multiple files to
ensure consistency and accurate tracking of recent events.
- Removed logging functionality for notifications state to streamline
the logging process.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-02-26 14:04:24 -05:00
committed by GitHub
parent 7044713508
commit bde37d6751
9 changed files with 20 additions and 129 deletions

View File

@@ -8,7 +8,6 @@ import { dynamicRemoteAccessReducer } from '@app/store/modules/dynamic-remote-ac
import { dynamix } from '@app/store/modules/dynamix.js'; import { dynamix } from '@app/store/modules/dynamix.js';
import { emhttp } from '@app/store/modules/emhttp.js'; import { emhttp } from '@app/store/modules/emhttp.js';
import { mothership } from '@app/store/modules/minigraph.js'; import { mothership } from '@app/store/modules/minigraph.js';
import { notificationReducer } from '@app/store/modules/notifications.js';
import { paths } from '@app/store/modules/paths.js'; import { paths } from '@app/store/modules/paths.js';
import { registration } from '@app/store/modules/registration.js'; import { registration } from '@app/store/modules/registration.js';
import { remoteGraphQLReducer } from '@app/store/modules/remote-graphql.js'; import { remoteGraphQLReducer } from '@app/store/modules/remote-graphql.js';
@@ -23,7 +22,6 @@ export const store = configureStore({
emhttp: emhttp.reducer, emhttp: emhttp.reducer,
registration: registration.reducer, registration: registration.reducer,
remoteGraphQL: remoteGraphQLReducer, remoteGraphQL: remoteGraphQLReducer,
notifications: notificationReducer,
cache: cache.reducer, cache: cache.reducer,
docker: docker.reducer, docker: docker.reducer,
upnp: upnp.reducer, upnp: upnp.reducer,
@@ -46,7 +44,6 @@ export const getters = {
dynamix: () => store.getState().dynamix, dynamix: () => store.getState().dynamix,
emhttp: () => store.getState().emhttp, emhttp: () => store.getState().emhttp,
minigraph: () => store.getState().minigraph, minigraph: () => store.getState().minigraph,
notifications: () => store.getState().notifications,
paths: () => store.getState().paths, paths: () => store.getState().paths,
registration: () => store.getState().registration, registration: () => store.getState().registration,
remoteGraphQL: () => store.getState().remoteGraphQL, remoteGraphQL: () => store.getState().remoteGraphQL,

View File

@@ -1,21 +0,0 @@
import { logger } from '@app/core/log.js';
import { startAppListening } from '@app/store/listeners/listener-middleware.js';
import { clearAllNotifications } from '@app/store/modules/notifications.js';
export const enableNotificationPathListener = () =>
startAppListening({
predicate(_, currentState, previousState) {
if (
currentState.dynamix.notify?.path !== '' &&
previousState.dynamix.notify?.path !== currentState.dynamix.notify?.path
) {
return true;
}
return false;
},
async effect(_, { dispatch }) {
logger.debug('Notification Path Changed or Loaded, Recreating Watcher');
dispatch(clearAllNotifications());
},
});

View File

@@ -1,92 +0,0 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import type { Notification } from '@app/graphql/generated/api/types.js';
import { logger } from '@app/core/log.js';
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { type NotificationIni } from '@app/core/types/states/notification.js';
import { parseConfig } from '@app/core/utils/misc/parse-config.js';
import { NotificationSchema } from '@app/graphql/generated/api/operations.js';
import { Importance, NotificationType } from '@app/graphql/generated/api/types.js';
import { type AppDispatch, type RootState } from '@app/store/index.js';
interface NotificationState {
notifications: Record<string, Notification>;
}
const notificationInitialState: NotificationState = {
notifications: {},
};
const fileImportanceToGqlImportance = (importance: NotificationIni['importance']): Importance => {
switch (importance) {
case 'alert':
return Importance.ALERT;
case 'warning':
return Importance.WARNING;
default:
return Importance.INFO;
}
};
const parseNotificationDateToIsoDate = (unixStringSeconds: string | undefined): string | null => {
if (unixStringSeconds && !isNaN(Number(unixStringSeconds))) {
return new Date(Number(unixStringSeconds) * 1_000).toISOString();
}
return null;
};
export const loadNotification = createAsyncThunk<
{ id: string; notification: Notification },
{ path: string },
{ state: RootState; dispatch: AppDispatch }
>('notifications/loadNotification', ({ path }) => {
const notificationFile = parseConfig<NotificationIni>({
filePath: path,
type: 'ini',
});
const notification: Notification = {
id: path,
title: notificationFile.event,
subject: notificationFile.subject,
description: notificationFile.description ?? '',
importance: fileImportanceToGqlImportance(notificationFile.importance),
link: notificationFile.link,
timestamp: parseNotificationDateToIsoDate(notificationFile.timestamp),
type: NotificationType.UNREAD,
};
const convertedNotification = NotificationSchema().parse(notification);
if (convertedNotification) {
pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION, { notificationAdded: convertedNotification });
return { id: path, notification: convertedNotification };
}
throw new Error('Failed to parse notification');
});
export const notificationsStore = createSlice({
name: 'notifications',
initialState: notificationInitialState,
reducers: {
clearNotification: (state, action: PayloadAction<{ path: string }>) => {
if (state.notifications[action.payload.path]) {
delete state.notifications[action.payload.path];
}
},
clearAllNotifications: (state) => {
state.notifications = {};
},
},
extraReducers: (builder) => {
builder.addCase(loadNotification.fulfilled, (state, { payload }) => {
state.notifications[payload.id] = payload.notification;
});
builder.addCase(loadNotification.rejected, (_, action) => {
logger.debug('Failed to load notification with error %o', action.error);
});
},
});
export const notificationReducer = notificationsStore.reducer;
export const { clearNotification, clearAllNotifications } = notificationsStore.actions;

View File

@@ -41,10 +41,6 @@ export const startStoreSync = async () => {
join(state.paths.states, 'graphql.log'), join(state.paths.states, 'graphql.log'),
JSON.stringify(state.minigraph, null, 2) JSON.stringify(state.minigraph, null, 2)
); );
writeFileSync(
join(state.paths.states, 'notifications.log'),
JSON.stringify(state.notifications, null, 2)
);
} }
lastState = state; lastState = state;

View File

@@ -33,6 +33,10 @@ import { batchProcess, formatDatetime, isFulfilled, isRejected, unraidTimestamp
export class NotificationsService { export class NotificationsService {
private logger = new Logger(NotificationsService.name); private logger = new Logger(NotificationsService.name);
private static watcher: FSWatcher | null = null; private static watcher: FSWatcher | null = null;
/**
* The path to the notification directory - will be updated if the user changes the notifier path
*/
private path: string | null = null;
private static overview: NotificationOverview = { private static overview: NotificationOverview = {
unread: { unread: {
@@ -50,7 +54,8 @@ export class NotificationsService {
}; };
constructor() { constructor() {
NotificationsService.watcher = this.getNotificationsWatcher(); this.path = getters.dynamix().notify!.path;
void this.getNotificationsWatcher(this.path);
} }
/** /**
@@ -63,6 +68,13 @@ export class NotificationsService {
*/ */
public paths(): Record<'basePath' | NotificationType, string> { public paths(): Record<'basePath' | NotificationType, string> {
const basePath = getters.dynamix().notify!.path; const basePath = getters.dynamix().notify!.path;
if (this.path !== basePath) {
// Recreate the watcher with force = true
void this.getNotificationsWatcher(basePath, true);
this.path = basePath;
}
const makePath = (type: NotificationType) => join(basePath, type.toLowerCase()); const makePath = (type: NotificationType) => join(basePath, type.toLowerCase());
return { return {
basePath, basePath,
@@ -78,12 +90,11 @@ export class NotificationsService {
* events to their event handlers. * events to their event handlers.
*------------------------------------------------------------------------**/ *------------------------------------------------------------------------**/
private getNotificationsWatcher() { private async getNotificationsWatcher(basePath: string, recreate = false): Promise<FSWatcher> {
const { basePath } = this.paths(); if (NotificationsService.watcher && !recreate) {
if (NotificationsService.watcher) {
return NotificationsService.watcher; return NotificationsService.watcher;
} }
await NotificationsService.watcher?.close().catch((e) => this.logger.error(e));
NotificationsService.watcher = watch(basePath, { usePolling: CHOKIDAR_USEPOLLING }).on( NotificationsService.watcher = watch(basePath, { usePolling: CHOKIDAR_USEPOLLING }).on(
'add', 'add',