From 9f570d4dbfc8de7e084dc838f281b04c7c38e96b Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sat, 18 Jan 2025 15:47:40 -0800 Subject: [PATCH] Convert signalR to TypeScript --- frontend/src/Components/Page/Page.tsx | 4 +- frontend/src/Components/SignalRConnector.js | 422 -------------------- frontend/src/Components/SignalRListener.tsx | 358 +++++++++++++++++ frontend/src/Utilities/SignalRLogger.ts | 47 +++ frontend/src/Utilities/pagePopulator.ts | 2 +- 5 files changed, 408 insertions(+), 425 deletions(-) delete mode 100644 frontend/src/Components/SignalRConnector.js create mode 100644 frontend/src/Components/SignalRListener.tsx create mode 100644 frontend/src/Utilities/SignalRLogger.ts diff --git a/frontend/src/Components/Page/Page.tsx b/frontend/src/Components/Page/Page.tsx index f7132ec32..688676e05 100644 --- a/frontend/src/Components/Page/Page.tsx +++ b/frontend/src/Components/Page/Page.tsx @@ -4,7 +4,7 @@ import AppUpdatedModal from 'App/AppUpdatedModal'; import ColorImpairedContext from 'App/ColorImpairedContext'; import ConnectionLostModal from 'App/ConnectionLostModal'; import AppState from 'App/State/AppState'; -import SignalRConnector from 'Components/SignalRConnector'; +import SignalRListener from 'Components/SignalRListener'; import AuthenticationRequiredModal from 'FirstRun/AuthenticationRequiredModal'; import useAppPage from 'Helpers/Hooks/useAppPage'; import { saveDimensions } from 'Store/Actions/appActions'; @@ -87,7 +87,7 @@ function Page({ children }: PageProps) { return (
- + diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js deleted file mode 100644 index e59669883..000000000 --- a/frontend/src/Components/SignalRConnector.js +++ /dev/null @@ -1,422 +0,0 @@ -import * as signalR from '@microsoft/signalr/dist/browser/signalr.js'; -import PropTypes from 'prop-types'; -import { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setAppValue, setVersion } from 'Store/Actions/appActions'; -import { removeItem, update, updateItem } from 'Store/Actions/baseActions'; -import { fetchCommands, finishCommand, updateCommand } from 'Store/Actions/commandActions'; -import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; -import { fetchSeries } from 'Store/Actions/seriesActions'; -import { fetchQualityDefinitions } from 'Store/Actions/settingsActions'; -import { fetchHealth } from 'Store/Actions/systemActions'; -import { fetchTagDetails, fetchTags } from 'Store/Actions/tagActions'; -import { repopulatePage } from 'Utilities/pagePopulator'; -import titleCase from 'Utilities/String/titleCase'; - -function getHandlerName(name) { - name = titleCase(name); - name = name.replace('/', ''); - - return `handle${name}`; -} - -function createMapStateToProps() { - return createSelector( - (state) => state.app.isReconnecting, - (state) => state.app.isDisconnected, - (state) => state.queue.paged.isPopulated, - (isReconnecting, isDisconnected, isQueuePopulated) => { - return { - isReconnecting, - isDisconnected, - isQueuePopulated - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchCommands: fetchCommands, - dispatchUpdateCommand: updateCommand, - dispatchFinishCommand: finishCommand, - dispatchSetAppValue: setAppValue, - dispatchSetVersion: setVersion, - dispatchUpdate: update, - dispatchUpdateItem: updateItem, - dispatchRemoveItem: removeItem, - dispatchFetchHealth: fetchHealth, - dispatchFetchQualityDefinitions: fetchQualityDefinitions, - dispatchFetchQueue: fetchQueue, - dispatchFetchQueueDetails: fetchQueueDetails, - dispatchFetchRootFolders: fetchRootFolders, - dispatchFetchSeries: fetchSeries, - dispatchFetchTags: fetchTags, - dispatchFetchTagDetails: fetchTagDetails -}; - -function Logger(minimumLogLevel) { - this.minimumLogLevel = minimumLogLevel; -} - -Logger.prototype.cleanse = function(message) { - const apikey = new RegExp(`access_token=${encodeURIComponent(window.Sonarr.apiKey)}`, 'g'); - return message.replace(apikey, 'access_token=(removed)'); -}; - -Logger.prototype.log = function(logLevel, message) { - // see https://github.com/aspnet/AspNetCore/blob/21c9e2cc954c10719878839cd3f766aca5f57b34/src/SignalR/clients/ts/signalr/src/Utils.ts#L147 - if (logLevel >= this.minimumLogLevel) { - switch (logLevel) { - case signalR.LogLevel.Critical: - case signalR.LogLevel.Error: - console.error(`[signalR] ${signalR.LogLevel[logLevel]}: ${this.cleanse(message)}`); - break; - case signalR.LogLevel.Warning: - console.warn(`[signalR] ${signalR.LogLevel[logLevel]}: ${this.cleanse(message)}`); - break; - case signalR.LogLevel.Information: - console.info(`[signalR] ${signalR.LogLevel[logLevel]}: ${this.cleanse(message)}`); - break; - default: - // console.debug only goes to attached debuggers in Node, so we use console.log for Trace and Debug - console.log(`[signalR] ${signalR.LogLevel[logLevel]}: ${this.cleanse(message)}`); - break; - } - } -}; - -class SignalRConnector extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.connection = null; - } - - componentDidMount() { - console.log('[signalR] starting'); - - const url = `${window.Sonarr.urlBase}/signalr/messages`; - - this.connection = new signalR.HubConnectionBuilder() - .configureLogging(new Logger(signalR.LogLevel.Information)) - .withUrl(`${url}?access_token=${encodeURIComponent(window.Sonarr.apiKey)}`) - .withAutomaticReconnect({ - nextRetryDelayInMilliseconds: (retryContext) => { - if (retryContext.elapsedMilliseconds > 180000) { - this.props.dispatchSetAppValue({ isDisconnected: true }); - } - return Math.min(retryContext.previousRetryCount, 10) * 1000; - } - }) - .build(); - - this.connection.onreconnecting(this.onReconnecting); - this.connection.onreconnected(this.onReconnected); - this.connection.onclose(this.onClose); - - this.connection.on('receiveMessage', this.onReceiveMessage); - - this.connection.start().then(this.onStart, this.onStartFail); - } - - componentWillUnmount() { - this.connection.stop(); - this.connection = null; - } - - // - // Control - handleMessage = (message) => { - const { - name, - body - } = message; - - const handler = this[getHandlerName(name)]; - - if (handler) { - handler(body); - return; - } - - console.error(`signalR: Unable to find handler for ${name}`); - }; - - handleCalendar = (body) => { - if (body.action === 'updated') { - this.props.dispatchUpdateItem({ - section: 'calendar', - updateOnly: true, - ...body.resource - }); - } - }; - - handleCommand = (body) => { - if (body.action === 'sync') { - this.props.dispatchFetchCommands(); - return; - } - - const resource = body.resource; - const status = resource.status; - - // Both successful and failed commands need to be - // completed, otherwise they spin until they time out. - - if (status === 'completed' || status === 'failed') { - this.props.dispatchFinishCommand(resource); - } else { - this.props.dispatchUpdateCommand(resource); - } - }; - - handleEpisode = (body) => { - if (body.action === 'updated') { - this.props.dispatchUpdateItem({ - section: 'episodes', - updateOnly: true, - ...body.resource - }); - } - }; - - handleEpisodefile = (body) => { - const section = 'episodeFiles'; - - if (body.action === 'updated') { - this.props.dispatchUpdateItem({ section, ...body.resource }); - - // Repopulate the page to handle recently imported file - repopulatePage('episodeFileUpdated'); - } else if (body.action === 'deleted') { - this.props.dispatchRemoveItem({ section, id: body.resource.id }); - - repopulatePage('episodeFileDeleted'); - } - }; - - handleDownloadclient = ({ action, resource }) => { - const section = 'settings.downloadClients'; - - if (action === 'created' || action === 'updated') { - this.props.dispatchUpdateItem({ section, ...resource }); - } else if (action === 'deleted') { - this.props.dispatchRemoveItem({ section, id: resource.id }); - } - }; - - handleHealth = () => { - this.props.dispatchFetchHealth(); - }; - - handleImportlist = ({ action, resource }) => { - const section = 'settings.importLists'; - - if (action === 'created' || action === 'updated') { - this.props.dispatchUpdateItem({ section, ...resource }); - } else if (action === 'deleted') { - this.props.dispatchRemoveItem({ section, id: resource.id }); - } - }; - - handleIndexer = ({ action, resource }) => { - const section = 'settings.indexers'; - - if (action === 'created' || action === 'updated') { - this.props.dispatchUpdateItem({ section, ...resource }); - } else if (action === 'deleted') { - this.props.dispatchRemoveItem({ section, id: resource.id }); - } - }; - - handleMetadata = ({ action, resource }) => { - const section = 'settings.metadata'; - - if (action === 'updated') { - this.props.dispatchUpdateItem({ section, ...resource }); - } - }; - - handleNotification = ({ action, resource }) => { - const section = 'settings.notifications'; - - if (action === 'created' || action === 'updated') { - this.props.dispatchUpdateItem({ section, ...resource }); - } else if (action === 'deleted') { - this.props.dispatchRemoveItem({ section, id: resource.id }); - } - }; - - handleSeries = (body) => { - const action = body.action; - const section = 'series'; - - if (action === 'updated') { - this.props.dispatchUpdateItem({ section, ...body.resource }); - - repopulatePage('seriesUpdated'); - } else if (action === 'deleted') { - this.props.dispatchRemoveItem({ section, id: body.resource.id }); - } - }; - - handleQualitydefinition = () => { - this.props.dispatchFetchQualityDefinitions(); - }; - - handleQueue = () => { - if (this.props.isQueuePopulated) { - this.props.dispatchFetchQueue(); - } - }; - - handleQueueDetails = () => { - this.props.dispatchFetchQueueDetails(); - }; - - handleQueueStatus = (body) => { - this.props.dispatchUpdate({ section: 'queue.status', data: body.resource }); - }; - - handleVersion = (body) => { - const version = body.version; - - this.props.dispatchSetVersion({ version }); - }; - - handleWantedCutoff = (body) => { - if (body.action === 'updated') { - this.props.dispatchUpdateItem({ - section: 'wanted.cutoffUnmet', - updateOnly: true, - ...body.resource - }); - } - }; - - handleWantedMissing = (body) => { - if (body.action === 'updated') { - this.props.dispatchUpdateItem({ - section: 'wanted.missing', - updateOnly: true, - ...body.resource - }); - } - }; - - handleSystemTask = () => { - this.props.dispatchFetchCommands(); - }; - - handleRootfolder = () => { - this.props.dispatchFetchRootFolders(); - }; - - handleTag = (body) => { - if (body.action === 'sync') { - this.props.dispatchFetchTags(); - this.props.dispatchFetchTagDetails(); - return; - } - }; - - // - // Listeners - - onStartFail = (error) => { - console.error('[signalR] failed to connect'); - console.error(error); - - this.props.dispatchSetAppValue({ - isConnected: false, - isReconnecting: false, - isDisconnected: false, - isRestarting: false - }); - }; - - onStart = () => { - console.debug('[signalR] connected'); - - this.props.dispatchSetAppValue({ - isConnected: true, - isReconnecting: false, - isDisconnected: false, - isRestarting: false - }); - }; - - onReconnecting = () => { - this.props.dispatchSetAppValue({ isReconnecting: true }); - }; - - onReconnected = () => { - - const { - dispatchFetchCommands, - dispatchFetchSeries, - dispatchSetAppValue - } = this.props; - - dispatchSetAppValue({ - isConnected: true, - isReconnecting: false, - isDisconnected: false, - isRestarting: false - }); - - // Repopulate the page (if a repopulator is set) to ensure things - // are in sync after reconnecting. - dispatchFetchSeries(); - dispatchFetchCommands(); - repopulatePage(); - }; - - onClose = () => { - console.debug('[signalR] connection closed'); - }; - - onReceiveMessage = (message) => { - console.debug('[signalR] received', message.name, message.body); - - this.handleMessage(message); - }; - - // - // Render - - render() { - return null; - } -} - -SignalRConnector.propTypes = { - isReconnecting: PropTypes.bool.isRequired, - isDisconnected: PropTypes.bool.isRequired, - isQueuePopulated: PropTypes.bool.isRequired, - dispatchFetchCommands: PropTypes.func.isRequired, - dispatchUpdateCommand: PropTypes.func.isRequired, - dispatchFinishCommand: PropTypes.func.isRequired, - dispatchSetAppValue: PropTypes.func.isRequired, - dispatchSetVersion: PropTypes.func.isRequired, - dispatchUpdate: PropTypes.func.isRequired, - dispatchUpdateItem: PropTypes.func.isRequired, - dispatchRemoveItem: PropTypes.func.isRequired, - dispatchFetchHealth: PropTypes.func.isRequired, - dispatchFetchQualityDefinitions: PropTypes.func.isRequired, - dispatchFetchQueue: PropTypes.func.isRequired, - dispatchFetchQueueDetails: PropTypes.func.isRequired, - dispatchFetchRootFolders: PropTypes.func.isRequired, - dispatchFetchSeries: PropTypes.func.isRequired, - dispatchFetchTags: PropTypes.func.isRequired, - dispatchFetchTagDetails: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(SignalRConnector); diff --git a/frontend/src/Components/SignalRListener.tsx b/frontend/src/Components/SignalRListener.tsx new file mode 100644 index 000000000..f892f4083 --- /dev/null +++ b/frontend/src/Components/SignalRListener.tsx @@ -0,0 +1,358 @@ +import signalR, { HubConnection } from '@microsoft/signalr'; +import { useEffect, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import ModelBase from 'App/ModelBase'; +import AppState from 'App/State/AppState'; +import Command from 'Commands/Command'; +import { setAppValue, setVersion } from 'Store/Actions/appActions'; +import { removeItem, update, updateItem } from 'Store/Actions/baseActions'; +import { + fetchCommands, + finishCommand, + updateCommand, +} from 'Store/Actions/commandActions'; +import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { fetchSeries } from 'Store/Actions/seriesActions'; +import { fetchQualityDefinitions } from 'Store/Actions/settingsActions'; +import { fetchHealth } from 'Store/Actions/systemActions'; +import { fetchTagDetails, fetchTags } from 'Store/Actions/tagActions'; +import { repopulatePage } from 'Utilities/pagePopulator'; +import SignalRLogger from 'Utilities/SignalRLogger'; + +type SignalRAction = 'sync' | 'created' | 'updated' | 'deleted'; + +interface SignalRMessage { + name: string; + body: { + action: SignalRAction; + resource: ModelBase; + version: string; + }; +} + +function SignalRListener() { + const dispatch = useDispatch(); + + const isQueuePopulated = useSelector( + (state: AppState) => state.queue.paged.isPopulated + ); + + const connection = useRef(null); + + const handleStartFail = useRef((error: unknown) => { + console.error('[signalR] failed to connect'); + console.error(error); + + dispatch( + setAppValue({ + isConnected: false, + isReconnecting: false, + isDisconnected: false, + isRestarting: false, + }) + ); + }); + + const handleStart = useRef(() => { + console.debug('[signalR] connected'); + + dispatch( + setAppValue({ + isConnected: true, + isReconnecting: false, + isDisconnected: false, + isRestarting: false, + }) + ); + }); + + const handleReconnecting = useRef(() => { + dispatch(setAppValue({ isReconnecting: true })); + }); + + const handleReconnected = useRef(() => { + dispatch( + setAppValue({ + isConnected: true, + isReconnecting: false, + isDisconnected: false, + isRestarting: false, + }) + ); + + // Repopulate the page (if a repopulator is set) to ensure things + // are in sync after reconnecting. + dispatch(fetchSeries()); + dispatch(fetchCommands()); + repopulatePage(); + }); + + const handleClose = useRef(() => { + console.debug('[signalR] connection closed'); + }); + + const handleReceiveMessage = useRef((message: SignalRMessage) => { + console.debug('[signalR] received', message.name, message.body); + + const { name, body } = message; + + if (name === 'calendar') { + if (body.action === 'updated') { + dispatch( + updateItem({ + section: 'calendar', + updateOnly: true, + ...body.resource, + }) + ); + return; + } + } + + if (name === 'command') { + if (body.action === 'sync') { + dispatch(fetchCommands()); + return; + } + + const resource = body.resource as Command; + const status = resource.status; + + // Both successful and failed commands need to be + // completed, otherwise they spin until they time out. + + if (status === 'completed' || status === 'failed') { + dispatch(finishCommand(resource)); + } else { + dispatch(updateCommand(resource)); + } + + return; + } + + if (name === 'downloadclient') { + const section = 'settings.downloadClients'; + + if (body.action === 'created' || body.action === 'updated') { + dispatch(updateItem({ section, ...body.resource })); + } else if (body.action === 'deleted') { + dispatch(removeItem({ section, id: body.resource.id })); + } + + return; + } + + if (name === 'episode') { + if (body.action === 'updated') { + dispatch( + updateItem({ + section: 'episodes', + updateOnly: true, + ...body.resource, + }) + ); + } + + return; + } + + if (name === 'episodefile') { + const section = 'episodeFiles'; + + if (body.action === 'updated') { + dispatch(updateItem({ section, ...body.resource })); + + // Repopulate the page to handle recently imported file + repopulatePage('episodeFileUpdated'); + } else if (body.action === 'deleted') { + dispatch(removeItem({ section, id: body.resource.id })); + + repopulatePage('episodeFileDeleted'); + } + + return; + } + + if (name === 'health') { + dispatch(fetchHealth()); + return; + } + + if (name === 'importlist') { + const section = 'settings.importLists'; + + if (body.action === 'created' || body.action === 'updated') { + dispatch(updateItem({ section, ...body.resource })); + } else if (body.action === 'deleted') { + dispatch(removeItem({ section, id: body.resource.id })); + } + + return; + } + + if (name === 'indexer') { + const section = 'settings.indexers'; + + if (body.action === 'created' || body.action === 'updated') { + dispatch(updateItem({ section, ...body.resource })); + } else if (body.action === 'deleted') { + dispatch(removeItem({ section, id: body.resource.id })); + } + + return; + } + + if (name === 'metadata') { + const section = 'settings.metadata'; + + if (body.action === 'updated') { + dispatch(updateItem({ section, ...body.resource })); + } + + return; + } + + if (name === 'notification') { + const section = 'settings.notifications'; + + if (body.action === 'created' || body.action === 'updated') { + dispatch(updateItem({ section, ...body.resource })); + } else if (body.action === 'deleted') { + dispatch(removeItem({ section, id: body.resource.id })); + } + + return; + } + + if (name === 'qualitydefinition') { + dispatch(fetchQualityDefinitions()); + return; + } + + if (name === 'queue') { + if (isQueuePopulated) { + dispatch(fetchQueue()); + } + + return; + } + + if (name === 'queue/details') { + dispatch(fetchQueueDetails()); + return; + } + + if (name === 'queue/status') { + dispatch(update({ section: 'queue.status', data: body.resource })); + return; + } + + if (name === 'rootfolder') { + dispatch(fetchRootFolders()); + + return; + } + + if (name === 'series') { + if (body.action === 'updated') { + dispatch(updateItem({ section: 'series', ...body.resource })); + + repopulatePage('seriesUpdated'); + } else if (body.action === 'deleted') { + dispatch(removeItem({ section: 'series', id: body.resource.id })); + } + + return; + } + + if (name === 'system/task') { + dispatch(fetchCommands()); + return; + } + + if (name === 'tag') { + if (body.action === 'sync') { + dispatch(fetchTags()); + dispatch(fetchTagDetails()); + } + + return; + } + + if (name === 'version') { + dispatch(setVersion({ version: body.version })); + return; + } + + if (name === 'wanted/cutoff') { + if (body.action === 'updated') { + dispatch( + updateItem({ + section: 'wanted.cutoffUnmet', + updateOnly: true, + ...body.resource, + }) + ); + } + + return; + } + + if (name === 'wanted/missing') { + if (body.action === 'updated') { + dispatch( + updateItem({ + section: 'wanted.missing', + updateOnly: true, + ...body.resource, + }) + ); + } + + return; + } + + console.error(`signalR: Unable to find handler for ${name}`); + }); + + useEffect(() => { + console.log('[signalR] starting'); + + const url = `${window.Sonarr.urlBase}/signalr/messages`; + + connection.current = new signalR.HubConnectionBuilder() + .configureLogging(new SignalRLogger(signalR.LogLevel.Information)) + .withUrl( + `${url}?access_token=${encodeURIComponent(window.Sonarr.apiKey)}` + ) + .withAutomaticReconnect({ + nextRetryDelayInMilliseconds: (retryContext) => { + if (retryContext.elapsedMilliseconds > 180000) { + dispatch(setAppValue({ isDisconnected: true })); + } + return Math.min(retryContext.previousRetryCount, 10) * 1000; + }, + }) + .build(); + + connection.current.onreconnecting(handleReconnecting.current); + connection.current.onreconnected(handleReconnected.current); + connection.current.onclose(handleClose.current); + + connection.current.on('receiveMessage', handleReceiveMessage.current); + + connection.current + .start() + .then(handleStart.current, handleStartFail.current); + + return () => { + connection.current?.stop(); + connection.current = null; + }; + }, [dispatch]); + + return null; +} + +export default SignalRListener; diff --git a/frontend/src/Utilities/SignalRLogger.ts b/frontend/src/Utilities/SignalRLogger.ts new file mode 100644 index 000000000..6c04b45ea --- /dev/null +++ b/frontend/src/Utilities/SignalRLogger.ts @@ -0,0 +1,47 @@ +import { LogLevel } from '@microsoft/signalr'; + +export default class SignalRLogger { + private _minimumLogLevel = LogLevel.Information; + + constructor(minimumLogLevel: LogLevel) { + this._minimumLogLevel = minimumLogLevel; + } + + public log(logLevel: LogLevel, message: string) { + // see https://github.com/aspnet/AspNetCore/blob/21c9e2cc954c10719878839cd3f766aca5f57b34/src/SignalR/clients/ts/signalr/src/Utils.ts#L147 + if (logLevel >= this._minimumLogLevel) { + switch (logLevel) { + case LogLevel.Critical: + case LogLevel.Error: + console.error( + `[signalR] ${LogLevel[logLevel]}: ${this._cleanse(message)}` + ); + break; + case LogLevel.Warning: + console.warn( + `[signalR] ${LogLevel[logLevel]}: ${this._cleanse(message)}` + ); + break; + case LogLevel.Information: + console.info( + `[signalR] ${LogLevel[logLevel]}: ${this._cleanse(message)}` + ); + break; + default: + // console.debug only goes to attached debuggers in Node, so we use console.log for Trace and Debug + console.log( + `[signalR] ${LogLevel[logLevel]}: ${this._cleanse(message)}` + ); + break; + } + } + } + + private _cleanse(message: string) { + const apikey = new RegExp( + `access_token=${encodeURIComponent(window.Sonarr.apiKey)}`, + 'g' + ); + return message.replace(apikey, 'access_token=(removed)'); + } +} diff --git a/frontend/src/Utilities/pagePopulator.ts b/frontend/src/Utilities/pagePopulator.ts index 45689f63a..389a3fcdb 100644 --- a/frontend/src/Utilities/pagePopulator.ts +++ b/frontend/src/Utilities/pagePopulator.ts @@ -18,7 +18,7 @@ export function unregisterPagePopulator(populator: Populator) { } } -export function repopulatePage(reason: string) { +export function repopulatePage(reason?: string) { if (!currentPopulator) { return; }