Convert signalR to TypeScript

pull/7640/head
Mark McDowall 1 month ago
parent 6f871a1bfb
commit c3f9cd12af
No known key found for this signature in database

@ -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 (
<ColorImpairedContext.Provider value={enableColorImpairedMode}>
<div className={styles.page}>
<SignalRConnector />
<SignalRListener />
<PageHeader />

@ -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);

@ -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<HubConnection | null>(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;

@ -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)');
}
}

@ -18,7 +18,7 @@ export function unregisterPagePopulator(populator: Populator) {
}
}
export function repopulatePage(reason: string) {
export function repopulatePage(reason?: string) {
if (!currentPopulator) {
return;
}

Loading…
Cancel
Save