From 60bb0ac063d47b2fb0ea384d47519ac15a6d4b28 Mon Sep 17 00:00:00 2001 From: Qstick Date: Thu, 30 Aug 2018 23:07:50 -0400 Subject: [PATCH] New: Queued Task/Command List View Co-Authored-By: Mark McDowall --- frontend/src/App/AppRoutes.js | 4 +- .../Artist/Details/ArtistDetailsConnector.js | 20 +- .../Details/ArtistDetailsSeasonConnector.js | 2 +- .../Artist/Editor/ArtistEditorConnector.js | 4 +- .../src/Artist/Index/ArtistIndexConnector.js | 6 +- frontend/src/Components/SignalRConnector.js | 97 ++++--- frontend/src/Store/Actions/commandActions.js | 24 +- .../createCommandExecutingSelector.js | 6 +- .../Store/Selectors/createCommandSelector.js | 4 +- .../src/System/Tasks/Queued/QueuedTaskRow.css | 31 ++ .../src/System/Tasks/Queued/QueuedTaskRow.js | 264 ++++++++++++++++++ .../Tasks/Queued/QueuedTaskRowConnector.js | 31 ++ .../src/System/Tasks/Queued/QueuedTasks.js | 89 ++++++ .../Tasks/Queued/QueuedTasksConnector.js | 46 +++ .../ScheduledTaskRow.css} | 0 .../Tasks/Scheduled/ScheduledTaskRow.js | 182 ++++++++++++ .../ScheduledTaskRowConnector.js} | 17 +- .../System/Tasks/Scheduled/ScheduledTasks.js | 79 ++++++ .../ScheduledTasksConnector.js} | 16 +- frontend/src/System/Tasks/TaskRow.js | 94 ------- frontend/src/System/Tasks/Tasks.js | 97 +------ .../Utilities/Command/isCommandComplete.js | 2 +- .../Utilities/Command/isCommandExecuting.js | 2 +- .../src/Utilities/Command/isCommandFailed.js | 8 +- src/Lidarr.Api.V1/Commands/CommandModule.cs | 16 +- src/Lidarr.Api.V1/Commands/CommandResource.cs | 3 + src/Lidarr.Api.V1/System/Tasks/TaskModule.cs | 7 +- .../LidarrRestModuleWithSignalR.cs | 18 +- .../Extensions/StringExtensions.cs | 8 + .../Messaging/Commands/CommandQueue.cs | 2 +- .../Messaging/Commands/CommandQueueManager.cs | 7 + 31 files changed, 898 insertions(+), 288 deletions(-) create mode 100644 frontend/src/System/Tasks/Queued/QueuedTaskRow.css create mode 100644 frontend/src/System/Tasks/Queued/QueuedTaskRow.js create mode 100644 frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js create mode 100644 frontend/src/System/Tasks/Queued/QueuedTasks.js create mode 100644 frontend/src/System/Tasks/Queued/QueuedTasksConnector.js rename frontend/src/System/Tasks/{TaskRow.css => Scheduled/ScheduledTaskRow.css} (100%) create mode 100644 frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js rename frontend/src/System/Tasks/{TaskRowConnector.js => Scheduled/ScheduledTaskRowConnector.js} (80%) create mode 100644 frontend/src/System/Tasks/Scheduled/ScheduledTasks.js rename frontend/src/System/Tasks/{TasksConnector.js => Scheduled/ScheduledTasksConnector.js} (66%) delete mode 100644 frontend/src/System/Tasks/TaskRow.js diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index 7a75d774e..9e15ece36 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -30,7 +30,7 @@ import TagSettings from 'Settings/Tags/TagSettings'; import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; import UISettingsConnector from 'Settings/UI/UISettingsConnector'; import Status from 'System/Status/Status'; -import TasksConnector from 'System/Tasks/TasksConnector'; +import Tasks from 'System/Tasks/Tasks'; import BackupsConnector from 'System/Backup/BackupsConnector'; import UpdatesConnector from 'System/Updates/UpdatesConnector'; import LogsTableConnector from 'System/Events/LogsTableConnector'; @@ -218,7 +218,7 @@ function AppRoutes(props) { command.name === commandNames.REFRESH_ARTIST && !command.body.artistId); + const isArtistRefreshing = isCommandExecuting(findCommand(commands, { name: commandNames.REFRESH_ARTIST, artistId: artist.id })); + const artistRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_ARTIST }); + const allArtistRefreshing = ( + isCommandExecuting(artistRefreshingCommand) && + !artistRefreshingCommand.body.artistId + ); const isRefreshing = isArtistRefreshing || allArtistRefreshing; - const isSearching = !!findCommand(commands, { name: commandNames.ARTIST_SEARCH, artistId: artist.id }); - const isRenamingFiles = !!findCommand(commands, { name: commandNames.RENAME_FILES, artistId: artist.id }); + const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.ARTIST_SEARCH, artistId: artist.id })); + const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, artistId: artist.id })); + const isRenamingArtistCommand = findCommand(commands, { name: commandNames.RENAME_ARTIST }); - const isRenamingArtist = !!(isRenamingArtistCommand && isRenamingArtistCommand.body.artistId.indexOf(artist.id) > -1); + const isRenamingArtist = ( + isCommandExecuting(isRenamingArtistCommand) && + isRenamingArtistCommand.body.artistId.indexOf(artist.id) > -1 + ); const isFetching = albums.isFetching || trackFiles.isFetching; const isPopulated = albums.isPopulated && trackFiles.isPopulated; diff --git a/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.js b/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.js index e9eeeab3a..11202c7ac 100644 --- a/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.js +++ b/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { findCommand } from 'Utilities/Command'; +import { findCommand, isCommandExecuting } from 'Utilities/Command'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createArtistSelector from 'Store/Selectors/createArtistSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; diff --git a/frontend/src/Artist/Editor/ArtistEditorConnector.js b/frontend/src/Artist/Editor/ArtistEditorConnector.js index 3190fbc83..d029bbec2 100644 --- a/frontend/src/Artist/Editor/ArtistEditorConnector.js +++ b/frontend/src/Artist/Editor/ArtistEditorConnector.js @@ -3,7 +3,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; -import createCommandSelector from 'Store/Selectors/createCommandSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import { setArtistEditorSort, setArtistEditorFilter, saveArtistEditor } from 'Store/Actions/artistEditorActions'; import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; import { executeCommand } from 'Store/Actions/commandActions'; @@ -15,7 +15,7 @@ function createMapStateToProps() { (state) => state.settings.languageProfiles, (state) => state.settings.metadataProfiles, createClientSideCollectionSelector('artist', 'artistEditor'), - createCommandSelector(commandNames.RENAME_ARTIST), + createCommandExecutingSelector(commandNames.RENAME_ARTIST), (languageProfiles, metadataProfiles, artist, isOrganizingArtist) => { return { isOrganizingArtist, diff --git a/frontend/src/Artist/Index/ArtistIndexConnector.js b/frontend/src/Artist/Index/ArtistIndexConnector.js index 3140861e1..0c22d2556 100644 --- a/frontend/src/Artist/Index/ArtistIndexConnector.js +++ b/frontend/src/Artist/Index/ArtistIndexConnector.js @@ -5,7 +5,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import dimensions from 'Styles/Variables/dimensions'; -import createCommandSelector from 'Store/Selectors/createCommandSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import { fetchArtist } from 'Store/Actions/artistActions'; import scrollPositions from 'Store/scrollPositions'; @@ -47,8 +47,8 @@ function getScrollTop(view, scrollTop, isSmallScreen) { function createMapStateToProps() { return createSelector( createClientSideCollectionSelector('artist', 'artistIndex'), - createCommandSelector(commandNames.REFRESH_ARTIST), - createCommandSelector(commandNames.RSS_SYNC), + createCommandExecutingSelector(commandNames.REFRESH_ARTIST), + createCommandExecutingSelector(commandNames.RSS_SYNC), createDimensionsSelector(), ( artist, diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js index 1c7439aa6..e1f689035 100644 --- a/frontend/src/Components/SignalRConnector.js +++ b/frontend/src/Components/SignalRConnector.js @@ -6,7 +6,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { repopulatePage } from 'Utilities/pagePopulator'; import titleCase from 'Utilities/String/titleCase'; -import { updateCommand, finishCommand } from 'Store/Actions/commandActions'; +import { fetchCommands, updateCommand, finishCommand } from 'Store/Actions/commandActions'; import { setAppValue, setVersion } from 'Store/Actions/appActions'; import { update, updateItem, removeItem } from 'Store/Actions/baseActions'; import { fetchHealth } from 'Store/Actions/systemActions'; @@ -58,16 +58,17 @@ function createMapStateToProps() { } const mapDispatchToProps = { - updateCommand, - finishCommand, - setAppValue, - setVersion, - update, - updateItem, - removeItem, - fetchHealth, - fetchQueue, - fetchQueueDetails + dispatchFetchCommands: fetchCommands, + dispatchUpdateCommand: updateCommand, + dispatchFinishCommand: finishCommand, + dispatchSetAppValue: setAppValue, + dispatchSetVersion: setVersion, + dispatchUpdate: update, + dispatchUpdateItem: updateItem, + dispatchRemoveItem: removeItem, + dispatchFetchHealth: fetchHealth, + dispatchFetchQueue: fetchQueue, + dispatchFetchQueueDetails: fetchQueueDetails }; class SignalRConnector extends Component { @@ -146,7 +147,7 @@ class SignalRConnector extends Component { handleCalendar = (body) => { if (body.action === 'updated') { - this.props.updateItem({ + this.props.dispatchUpdateItem({ section: 'calendar', updateOnly: true, ...body.resource @@ -155,22 +156,27 @@ class SignalRConnector extends Component { } handleCommand = (body) => { + if (body.action === 'sync') { + this.props.dispatchFetchCommands(); + return; + } + const resource = body.resource; - const state = resource.state; + const status = resource.status; // Both sucessful and failed commands need to be // completed, otherwise they spin until they timeout. - if (state === 'completed' || state === 'failed') { - this.props.finishCommand(resource); + if (status === 'completed' || status === 'failed') { + this.props.dispatchFinishCommand(resource); } else { - this.props.updateCommand(resource); + this.props.dispatchUpdateCommand(resource); } } handleAlbum = (body) => { if (body.action === 'updated') { - this.props.updateItem({ + this.props.dispatchUpdateItem({ section: 'albums', updateOnly: true, ...body.resource @@ -180,7 +186,7 @@ class SignalRConnector extends Component { handleTrack = (body) => { if (body.action === 'updated') { - this.props.updateItem({ + this.props.dispatchUpdateItem({ section: 'tracks', updateOnly: true, ...body.resource @@ -192,14 +198,14 @@ class SignalRConnector extends Component { const section = 'trackFiles'; if (body.action === 'updated') { - this.props.updateItem({ section, ...body.resource }); + this.props.dispatchUpdateItem({ section, ...body.resource }); } else if (body.action === 'deleted') { - this.props.removeItem({ section, id: body.resource.id }); + this.props.dispatchRemoveItem({ section, id: body.resource.id }); } } handleHealth = () => { - this.props.fetchHealth(); + this.props.dispatchFetchHealth(); } handleArtist = (body) => { @@ -207,35 +213,35 @@ class SignalRConnector extends Component { const section = 'artist'; if (action === 'updated') { - this.props.updateItem({ section, ...body.resource }); + this.props.dispatchUpdateItem({ section, ...body.resource }); } else if (action === 'deleted') { - this.props.removeItem({ section, id: body.resource.id }); + this.props.dispatchRemoveItem({ section, id: body.resource.id }); } } handleQueue = () => { if (this.props.isQueuePopulated) { - this.props.fetchQueue(); + this.props.dispatchFetchQueue(); } } handleQueueDetails = () => { - this.props.fetchQueueDetails(); + this.props.dispatchFetchQueueDetails(); } handleQueueStatus = (body) => { - this.props.update({ section: 'queue.status', data: body.resource }); + this.props.dispatchUpdate({ section: 'queue.status', data: body.resource }); } handleVersion = (body) => { const version = body.Version; - this.props.setVersion({ version }); + this.props.dispatchSetVersion({ version }); } handleWantedCutoff = (body) => { if (body.action === 'updated') { - this.props.updateItem({ + this.props.dispatchUpdateItem({ section: 'cutoffUnmet', updateOnly: true, ...body.resource @@ -245,7 +251,7 @@ class SignalRConnector extends Component { handleWantedMissing = (body) => { if (body.action === 'updated') { - this.props.updateItem({ + this.props.dispatchUpdateItem({ section: 'missing', updateOnly: true, ...body.resource @@ -268,14 +274,20 @@ class SignalRConnector extends Component { // Clear disconnected time this.disconnectedTime = null; + const { + dispatchFetchCommands, + dispatchSetAppValue + } = this.props; + // Repopulate the page (if a repopulator is set) to ensure things // are in sync after reconnecting. if (this.props.isReconnecting || this.props.isDisconnected) { + dispatchFetchCommands(); repopulatePage(); } - this.props.setAppValue({ + dispatchSetAppValue({ isConnected: true, isReconnecting: false, isDisconnected: false, @@ -305,7 +317,7 @@ class SignalRConnector extends Component { this.disconnectedTime = Math.floor(new Date().getTime() / 1000); } - this.props.setAppValue({ + this.props.dispatchSetAppValue({ isReconnecting: true }); } @@ -319,7 +331,7 @@ class SignalRConnector extends Component { this.disconnectedTime = Math.floor(new Date().getTime() / 1000); } - this.props.setAppValue({ + this.props.dispatchSetAppValue({ isConnected: false, isReconnecting: true, isDisconnected: isAppDisconnected(this.disconnectedTime) @@ -340,16 +352,17 @@ SignalRConnector.propTypes = { isReconnecting: PropTypes.bool.isRequired, isDisconnected: PropTypes.bool.isRequired, isQueuePopulated: PropTypes.bool.isRequired, - updateCommand: PropTypes.func.isRequired, - finishCommand: PropTypes.func.isRequired, - setAppValue: PropTypes.func.isRequired, - setVersion: PropTypes.func.isRequired, - update: PropTypes.func.isRequired, - updateItem: PropTypes.func.isRequired, - removeItem: PropTypes.func.isRequired, - fetchHealth: PropTypes.func.isRequired, - fetchQueue: PropTypes.func.isRequired, - fetchQueueDetails: PropTypes.func.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, + dispatchFetchQueue: PropTypes.func.isRequired, + dispatchFetchQueueDetails: PropTypes.func.isRequired }; export default connect(createMapStateToProps, mapDispatchToProps)(SignalRConnector); diff --git a/frontend/src/Store/Actions/commandActions.js b/frontend/src/Store/Actions/commandActions.js index 1c399c88e..e0dd81d3d 100644 --- a/frontend/src/Store/Actions/commandActions.js +++ b/frontend/src/Store/Actions/commandActions.js @@ -7,6 +7,7 @@ import { messageTypes } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createFetchHandler from './Creators/createFetchHandler'; import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; import { showMessage, hideMessage } from './appActions'; import { updateItem } from './baseActions'; @@ -35,6 +36,7 @@ export const defaultState = { export const FETCH_COMMANDS = 'commands/fetchCommands'; export const EXECUTE_COMMAND = 'commands/executeCommand'; +export const CANCEL_COMMAND = 'commands/cancelCommand'; export const ADD_COMMAND = 'commands/updateCommand'; export const UPDATE_COMMAND = 'commands/finishCommand'; export const FINISH_COMMAND = 'commands/addCommand'; @@ -45,6 +47,7 @@ export const REMOVE_COMMAND = 'commands/removeCommand'; export const fetchCommands = createThunk(FETCH_COMMANDS); export const executeCommand = createThunk(EXECUTE_COMMAND); +export const cancelCommand = createThunk(CANCEL_COMMAND); export const updateCommand = createThunk(UPDATE_COMMAND); export const finishCommand = createThunk(FINISH_COMMAND); export const addCommand = createAction(ADD_COMMAND); @@ -60,7 +63,7 @@ function showCommandMessage(payload, dispatch) { trigger, message, body = {}, - state + status } = payload; const { @@ -75,10 +78,10 @@ function showCommandMessage(payload, dispatch) { let type = messageTypes.INFO; let hideAfter = 0; - if (state === 'completed') { + if (status === 'completed') { type = messageTypes.SUCCESS; hideAfter = 4; - } else if (state === 'failed') { + } else if (status === 'failed') { type = messageTypes.ERROR; hideAfter = trigger === 'manual' ? 10 : 4; } @@ -95,8 +98,7 @@ function showCommandMessage(payload, dispatch) { function scheduleRemoveCommand(command, dispatch) { const { id, - status, - body + status } = command; if (status === 'queued') { @@ -109,12 +111,6 @@ function scheduleRemoveCommand(command, dispatch) { clearTimeout(timeoutId); } - // 5 minute timeout for executing disk access commands and - // 30 seconds for all other commands. - const timeout = body.requiresDiskAccess && status === 'started' ? - 60000 * 5 : - 30000; - removeCommandTimeoutIds[id] = setTimeout(() => { dispatch(batchActions([ removeCommand({ section: 'commands', id }), @@ -122,7 +118,7 @@ function scheduleRemoveCommand(command, dispatch) { ])); delete removeCommandTimeoutIds[id]; - }, timeout); + }, 60000 * 5); } // @@ -159,6 +155,8 @@ export const actionHandlers = handleThunks({ }); }, + [CANCEL_COMMAND]: createRemoveItemHandler(section, '/command'), + [UPDATE_COMMAND]: function(getState, payload, dispatch) { dispatch(updateItem({ section: 'commands', ...payload })); @@ -178,7 +176,7 @@ export const actionHandlers = handleThunks({ } }); - dispatch(removeCommand({ section: 'commands', ...payload })); + scheduleRemoveCommand(payload, dispatch); showCommandMessage(payload, dispatch); } diff --git a/frontend/src/Store/Selectors/createCommandExecutingSelector.js b/frontend/src/Store/Selectors/createCommandExecutingSelector.js index ac79194ad..337b31f6a 100644 --- a/frontend/src/Store/Selectors/createCommandExecutingSelector.js +++ b/frontend/src/Store/Selectors/createCommandExecutingSelector.js @@ -1,12 +1,12 @@ -import _ from 'lodash'; import { createSelector } from 'reselect'; +import { findCommand, isCommandExecuting } from 'Utilities/Command'; import createCommandsSelector from './createCommandsSelector'; -function createCommandExecutingSelector(name) { +function createCommandExecutingSelector(name, contraints = {}) { return createSelector( createCommandsSelector(), (commands) => { - return _.some(commands, { name }); + return isCommandExecuting(findCommand(commands, { name, ...contraints })); } ); } diff --git a/frontend/src/Store/Selectors/createCommandSelector.js b/frontend/src/Store/Selectors/createCommandSelector.js index ff5bfe50a..5f69ab005 100644 --- a/frontend/src/Store/Selectors/createCommandSelector.js +++ b/frontend/src/Store/Selectors/createCommandSelector.js @@ -1,12 +1,12 @@ -import _ from 'lodash'; import { createSelector } from 'reselect'; +import { findCommand } from 'Utilities/Command'; import createCommandsSelector from './createCommandsSelector'; function createCommandSelector(name, contraints = {}) { return createSelector( createCommandsSelector(), (commands) => { - return _.some(commands, { name, ...contraints }); + return !!findCommand(commands, { name, ...contraints }); } ); } diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.css b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css new file mode 100644 index 000000000..30f86efff --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css @@ -0,0 +1,31 @@ +.trigger { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} + +.triggerContent { + display: flex; + justify-content: space-between; + width: 100%; +} + +.queued, +.started, +.ended { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 180px; +} + +.duration { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 20px; +} diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.js b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js new file mode 100644 index 000000000..8353a5325 --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js @@ -0,0 +1,264 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './QueuedTaskRow.css'; + +function getStatusIconProps(status, message) { + const title = titleCase(status); + + switch (status) { + case 'queued': + return { + name: icons.PENDING, + title + }; + + case 'started': + return { + name: icons.REFRESH, + isSpinning: true, + title + }; + + case 'completed': + return { + name: icons.CHECK, + kind: kinds.SUCCESS, + title: message === 'Completed' ? title : `${title}: ${message}` + }; + + case 'failed': + return { + name: icons.FATAL, + kind: kinds.ERROR, + title: `${title}: ${message}` + }; + + default: + return { + name: icons.UNKNOWN, + title + }; + } +} + +function getFormattedDates(props) { + const { + queued, + started, + ended, + showRelativeDates, + shortDateFormat + } = props; + + if (showRelativeDates) { + return { + queuedAt: moment(queued).fromNow(), + startedAt: started ? moment(started).fromNow() : '-', + endedAt: ended ? moment(ended).fromNow() : '-' + }; + } + + return { + queuedAt: formatDate(queued, shortDateFormat), + startedAt: started ? formatDate(started, shortDateFormat) : '-', + endedAt: ended ? formatDate(ended, shortDateFormat) : '-' + }; +} + +class QueuedTaskRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + ...getFormattedDates(props), + isCancelConfirmModalOpen: false + }; + + this._updateTimeoutId = null; + } + + componentDidMount() { + this.setUpdateTimer(); + } + + componentDidUpdate(prevProps) { + const { + queued, + started, + ended + } = this.props; + + if ( + queued !== prevProps.queued || + started !== prevProps.started || + ended !== prevProps.ended + ) { + this.setState(getFormattedDates(this.props)); + } + } + + componentWillUnmount() { + if (this._updateTimeoutId) { + this._updateTimeoutId = clearTimeout(this._updateTimeoutId); + } + } + + // + // Control + + setUpdateTimer() { + this._updateTimeoutId = setTimeout(() => { + this.setState(getFormattedDates(this.props)); + this.setUpdateTimer(); + }, 30000); + } + + // + // Listeners + + onCancelPress = () => { + this.setState({ + isCancelConfirmModalOpen: true + }); + } + + onAbortCancel = () => { + this.setState({ + isCancelConfirmModalOpen: false + }); + } + + // + // Render + + render() { + const { + trigger, + commandName, + queued, + started, + ended, + status, + duration, + message, + longDateFormat, + timeFormat, + onCancelPress + } = this.props; + + const { + queuedAt, + startedAt, + endedAt, + isCancelConfirmModalOpen + } = this.state; + + let triggerIcon = icons.UNKNOWN; + + if (trigger === 'manual') { + triggerIcon = icons.INTERACTIVE; + } else if (trigger === 'scheduled') { + triggerIcon = icons.SCHEDULED; + } + + return ( + + + + + + + + + + {commandName} + + + {queuedAt} + + + + {startedAt} + + + + {endedAt} + + + + {formatTimeSpan(duration)} + + + + { + status === 'queued' && + + } + + + + + ); + } +} + +QueuedTaskRow.propTypes = { + trigger: PropTypes.string.isRequired, + commandName: PropTypes.string.isRequired, + queued: PropTypes.string.isRequired, + started: PropTypes.string, + ended: PropTypes.string, + status: PropTypes.string.isRequired, + duration: PropTypes.string, + message: PropTypes.string, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onCancelPress: PropTypes.func.isRequired +}; + +export default QueuedTaskRow; diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js b/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js new file mode 100644 index 000000000..f55ab985a --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js @@ -0,0 +1,31 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { cancelCommand } from 'Store/Actions/commandActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import QueuedTaskRow from './QueuedTaskRow'; + +function createMapStateToProps() { + return createSelector( + createUISettingsSelector(), + (uiSettings) => { + return { + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onCancelPress() { + dispatch(cancelCommand({ + id: props.id + })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(QueuedTaskRow); diff --git a/frontend/src/System/Tasks/Queued/QueuedTasks.js b/frontend/src/System/Tasks/Queued/QueuedTasks.js new file mode 100644 index 000000000..a2fd526fa --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTasks.js @@ -0,0 +1,89 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import QueuedTaskRowConnector from './QueuedTaskRowConnector'; + +const columns = [ + { + name: 'trigger', + label: '', + isVisible: true + }, + { + name: 'commandName', + label: 'Name', + isVisible: true + }, + { + name: 'queued', + label: 'Queued', + isVisible: true + }, + { + name: 'started', + label: 'Started', + isVisible: true + }, + { + name: 'ended', + label: 'Ended', + isVisible: true + }, + { + name: 'duration', + label: 'Duration', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +function QueuedTasks(props) { + const { + isFetching, + isPopulated, + items + } = props; + + return ( +
+ { + isFetching && !isPopulated && + + } + + { + isPopulated && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } +
+ ); +} + +QueuedTasks.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired +}; + +export default QueuedTasks; diff --git a/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js new file mode 100644 index 000000000..5fa4d9ead --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchCommands } from 'Store/Actions/commandActions'; +import QueuedTasks from './QueuedTasks'; + +function createMapStateToProps() { + return createSelector( + (state) => state.commands, + (commands) => { + return commands; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchCommands: fetchCommands +}; + +class QueuedTasksConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchCommands(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueuedTasksConnector.propTypes = { + dispatchFetchCommands: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueuedTasksConnector); diff --git a/frontend/src/System/Tasks/TaskRow.css b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css similarity index 100% rename from frontend/src/System/Tasks/TaskRow.css rename to frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js new file mode 100644 index 000000000..82cedc720 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js @@ -0,0 +1,182 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import { icons } from 'Helpers/Props'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './ScheduledTaskRow.css'; + +function getFormattedDates(props) { + const { + lastExecution, + nextExecution, + interval, + showRelativeDates, + shortDateFormat + } = props; + + const isDisabled = interval === 0; + + if (showRelativeDates) { + return { + lastExecutionTime: moment(lastExecution).fromNow(), + nextExecutionTime: isDisabled ? '-' : moment(nextExecution).fromNow() + }; + } + + return { + lastExecutionTime: formatDate(lastExecution, shortDateFormat), + nextExecutionTime: isDisabled ? '-' : formatDate(nextExecution, shortDateFormat) + }; +} + +class ScheduledTaskRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = getFormattedDates(props); + + this._updateTimeoutId = null; + } + + componentDidMount() { + this.setUpdateTimer(); + } + + componentDidUpdate(prevProps) { + const { + lastExecution, + nextExecution + } = this.props; + + if ( + lastExecution !== prevProps.lastExecution || + nextExecution !== prevProps.nextExecution + ) { + this.setState(getFormattedDates(this.props)); + } + } + + componentWillUnmount() { + if (this._updateTimeoutId) { + this._updateTimeoutId = clearTimeout(this._updateTimeoutId); + } + } + + // + // Listeners + + setUpdateTimer() { + const { interval } = this.props; + const timeout = interval < 60 ? 10000 : 60000; + + this._updateTimeoutId = setTimeout(() => { + this.setState(getFormattedDates(this.props)); + this.setUpdateTimer(); + }, timeout); + } + + // + // Render + + render() { + const { + name, + interval, + lastExecution, + nextExecution, + isQueued, + isExecuting, + longDateFormat, + timeFormat, + onExecutePress + } = this.props; + + const { + lastExecutionTime, + nextExecutionTime + } = this.state; + + const isDisabled = interval === 0; + const executeNow = !isDisabled && moment().isAfter(nextExecution); + const hasNextExecutionTime = !isDisabled && !executeNow; + const duration = moment.duration(interval, 'minutes').humanize().replace(/an?(?=\s)/, '1'); + + return ( + + {name} + + {isDisabled ? 'disabled' : duration} + + + + {lastExecutionTime} + + + { + isDisabled && + - + } + + { + executeNow && isQueued && + queued + } + + { + executeNow && !isQueued && + now + } + + { + hasNextExecutionTime && + + {nextExecutionTime} + + } + + + + + + ); + } +} + +ScheduledTaskRow.propTypes = { + name: PropTypes.string.isRequired, + interval: PropTypes.number.isRequired, + lastExecution: PropTypes.string.isRequired, + nextExecution: PropTypes.string.isRequired, + isQueued: PropTypes.bool.isRequired, + isExecuting: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onExecutePress: PropTypes.func.isRequired +}; + +export default ScheduledTaskRow; diff --git a/frontend/src/System/Tasks/TaskRowConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js similarity index 80% rename from frontend/src/System/Tasks/TaskRowConnector.js rename to frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js index 035364034..79a0c6c87 100644 --- a/frontend/src/System/Tasks/TaskRowConnector.js +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js @@ -2,12 +2,12 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { findCommand } from 'Utilities/Command'; +import { findCommand, isCommandExecuting } from 'Utilities/Command'; import { executeCommand } from 'Store/Actions/commandActions'; import { fetchTask } from 'Store/Actions/systemActions'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import TaskRow from './TaskRow'; +import ScheduledTaskRow from './ScheduledTaskRow'; function createMapStateToProps() { return createSelector( @@ -15,10 +15,11 @@ function createMapStateToProps() { createCommandsSelector(), createUISettingsSelector(), (taskName, commands, uiSettings) => { - const isExecuting = !!findCommand(commands, { name: taskName }); + const command = findCommand(commands, { name: taskName }); return { - isExecuting, + isQueued: !!(command && command.state === 'queued'), + isExecuting: isCommandExecuting(command), showRelativeDates: uiSettings.showRelativeDates, shortDateFormat: uiSettings.shortDateFormat, longDateFormat: uiSettings.longDateFormat, @@ -46,7 +47,7 @@ function createMapDispatchToProps(dispatch, props) { }; } -class TaskRowConnector extends Component { +class ScheduledTaskRowConnector extends Component { // // Lifecycle @@ -75,17 +76,17 @@ class TaskRowConnector extends Component { } = this.props; return ( - ); } } -TaskRowConnector.propTypes = { +ScheduledTaskRowConnector.propTypes = { id: PropTypes.number.isRequired, isExecuting: PropTypes.bool.isRequired, dispatchFetchTask: PropTypes.func.isRequired }; -export default connect(createMapStateToProps, createMapDispatchToProps)(TaskRowConnector); +export default connect(createMapStateToProps, createMapDispatchToProps)(ScheduledTaskRowConnector); diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js new file mode 100644 index 000000000..7c6fe8a32 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import ScheduledTaskRowConnector from './ScheduledTaskRowConnector'; + +const columns = [ + { + name: 'name', + label: 'Name', + isVisible: true + }, + { + name: 'interval', + label: 'Interval', + isVisible: true + }, + { + name: 'lastExecution', + label: 'Last Execution', + isVisible: true + }, + { + name: 'nextExecution', + label: 'Next Execution', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +function ScheduledTasks(props) { + const { + isFetching, + isPopulated, + items + } = props; + + return ( +
+ { + isFetching && !isPopulated && + + } + + { + isPopulated && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } +
+ ); +} + +ScheduledTasks.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired +}; + +export default ScheduledTasks; diff --git a/frontend/src/System/Tasks/TasksConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js similarity index 66% rename from frontend/src/System/Tasks/TasksConnector.js rename to frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js index 492040674..8f418d3bb 100644 --- a/frontend/src/System/Tasks/TasksConnector.js +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js @@ -3,7 +3,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { fetchTasks } from 'Store/Actions/systemActions'; -import Tasks from './Tasks'; +import ScheduledTasks from './ScheduledTasks'; function createMapStateToProps() { return createSelector( @@ -15,16 +15,16 @@ function createMapStateToProps() { } const mapDispatchToProps = { - fetchTasks + dispatchFetchTasks: fetchTasks }; -class TasksConnector extends Component { +class ScheduledTasksConnector extends Component { // // Lifecycle componentDidMount() { - this.props.fetchTasks(); + this.props.dispatchFetchTasks(); } // @@ -32,15 +32,15 @@ class TasksConnector extends Component { render() { return ( - ); } } -TasksConnector.propTypes = { - fetchTasks: PropTypes.func.isRequired +ScheduledTasksConnector.propTypes = { + dispatchFetchTasks: PropTypes.func.isRequired }; -export default connect(createMapStateToProps, mapDispatchToProps)(TasksConnector); +export default connect(createMapStateToProps, mapDispatchToProps)(ScheduledTasksConnector); diff --git a/frontend/src/System/Tasks/TaskRow.js b/frontend/src/System/Tasks/TaskRow.js deleted file mode 100644 index f118842a7..000000000 --- a/frontend/src/System/Tasks/TaskRow.js +++ /dev/null @@ -1,94 +0,0 @@ -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React from 'react'; -import formatDate from 'Utilities/Date/formatDate'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import { icons } from 'Helpers/Props'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import TableRow from 'Components/Table/TableRow'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import styles from './TaskRow.css'; - -function TaskRow(props) { - const { - name, - interval, - lastExecution, - nextExecution, - isExecuting, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat, - onExecutePress - } = props; - - const disabled = interval === 0; - const executeNow = !disabled && moment().isAfter(nextExecution); - const hasNextExecutionTime = !disabled && !executeNow; - const duration = moment.duration(interval, 'minutes').humanize().replace(/an?(?=\s)/, '1'); - - return ( - - {name} - - {disabled ? 'disabled' : duration} - - - - {showRelativeDates ? moment(lastExecution).fromNow() : formatDate(lastExecution, shortDateFormat)} - - - { - disabled && - - - } - - { - executeNow && - now - } - - { - hasNextExecutionTime && - - {showRelativeDates ? moment(nextExecution).fromNow() : formatDate(nextExecution, shortDateFormat)} - - } - - - - - - ); -} - -TaskRow.propTypes = { - name: PropTypes.string.isRequired, - interval: PropTypes.number.isRequired, - lastExecution: PropTypes.string.isRequired, - nextExecution: PropTypes.string.isRequired, - isExecuting: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onExecutePress: PropTypes.func.isRequired -}; - -export default TaskRow; diff --git a/frontend/src/System/Tasks/Tasks.js b/frontend/src/System/Tasks/Tasks.js index ae2d75dbb..dbbb4d1bf 100644 --- a/frontend/src/System/Tasks/Tasks.js +++ b/frontend/src/System/Tasks/Tasks.js @@ -1,89 +1,18 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; +import React from 'react'; import PageContent from 'Components/Page/PageContent'; import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import TaskRowConnector from './TaskRowConnector'; - -const columns = [ - { - name: 'name', - label: 'Name', - isVisible: true - }, - { - name: 'interval', - label: 'Interval', - isVisible: true - }, - { - name: 'lastExecution', - label: 'Last Execution', - isVisible: true - }, - { - name: 'nextExecution', - label: 'Next Execution', - isVisible: true - }, - { - name: 'actions', - isVisible: true - } -]; - -class Tasks extends Component { - - // - // Render - - render() { - const { - isFetching, - isPopulated, - items - } = this.props; - - return ( - - - { - isFetching && !isPopulated && - - } - - { - isPopulated && - - - { - items.map((item) => { - return ( - - ); - }) - } - -
- } -
-
- ); - } - +import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector'; +import QueuedTasksConnector from './Queued/QueuedTasksConnector'; + +function Tasks() { + return ( + + + + + + + ); } -Tasks.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - items: PropTypes.array.isRequired -}; - export default Tasks; diff --git a/frontend/src/Utilities/Command/isCommandComplete.js b/frontend/src/Utilities/Command/isCommandComplete.js index e64737188..558ab801b 100644 --- a/frontend/src/Utilities/Command/isCommandComplete.js +++ b/frontend/src/Utilities/Command/isCommandComplete.js @@ -3,7 +3,7 @@ function isCommandComplete(command) { return false; } - return command.state === 'complete'; + return command.status === 'complete'; } export default isCommandComplete; diff --git a/frontend/src/Utilities/Command/isCommandExecuting.js b/frontend/src/Utilities/Command/isCommandExecuting.js index 4e2e6d8c4..8e637704e 100644 --- a/frontend/src/Utilities/Command/isCommandExecuting.js +++ b/frontend/src/Utilities/Command/isCommandExecuting.js @@ -3,7 +3,7 @@ function isCommandExecuting(command) { return false; } - return command.state === 'queued' || command.state === 'started'; + return command.status === 'queued' || command.status === 'started'; } export default isCommandExecuting; diff --git a/frontend/src/Utilities/Command/isCommandFailed.js b/frontend/src/Utilities/Command/isCommandFailed.js index f48d790e3..00e5ccdf2 100644 --- a/frontend/src/Utilities/Command/isCommandFailed.js +++ b/frontend/src/Utilities/Command/isCommandFailed.js @@ -3,10 +3,10 @@ function isCommandFailed(command) { return false; } - return command.state === 'failed' || - command.state === 'aborted' || - command.state === 'cancelled' || - command.state === 'orphaned'; + return command.status === 'failed' || + command.status === 'aborted' || + command.status === 'cancelled' || + command.status === 'orphaned'; } export default isCommandFailed; diff --git a/src/Lidarr.Api.V1/Commands/CommandModule.cs b/src/Lidarr.Api.V1/Commands/CommandModule.cs index 59668931d..088a52698 100644 --- a/src/Lidarr.Api.V1/Commands/CommandModule.cs +++ b/src/Lidarr.Api.V1/Commands/CommandModule.cs @@ -32,6 +32,7 @@ namespace Lidarr.Api.V1.Commands GetResourceById = GetCommand; CreateResource = StartCommand; GetResourceAll = GetStartedCommands; + DeleteResource = CancelCommand; PostValidator.RuleFor(c => c.Name).NotBlank(); @@ -62,7 +63,13 @@ namespace Lidarr.Api.V1.Commands private List GetStartedCommands() { - return _commandQueueManager.GetStarted().ToResource(); + return _commandQueueManager.All().ToResource(); + } + + private void CancelCommand(int id) + { + // TODO: Cancel the existing command + // Executing tasks should preferably exit gracefully } public void Handle(CommandUpdatedEvent message) @@ -75,6 +82,13 @@ namespace Lidarr.Api.V1.Commands } _debouncer.Execute(); } + + if (message.Command.Name == typeof(MessagingCleanupCommand).Name.Replace("Command", "") && + message.Command.Status == CommandStatus.Completed) + { + BroadcastResourceChange(ModelAction.Sync); + } + } private void SendUpdates() diff --git a/src/Lidarr.Api.V1/Commands/CommandResource.cs b/src/Lidarr.Api.V1/Commands/CommandResource.cs index 77d2cc295..dff2bf782 100644 --- a/src/Lidarr.Api.V1/Commands/CommandResource.cs +++ b/src/Lidarr.Api.V1/Commands/CommandResource.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Messaging.Commands; using Lidarr.Http.REST; @@ -10,6 +11,7 @@ namespace Lidarr.Api.V1.Commands public class CommandResource : RestResource { public string Name { get; set; } + public string CommandName { get; set; } public string Message { get; set; } public Command Body { get; set; } public CommandPriority Priority { get; set; } @@ -75,6 +77,7 @@ namespace Lidarr.Api.V1.Commands Id = model.Id, Name = model.Name, + CommandName = model.Name.SplitCamelCase(), Message = model.Message, Body = model.Body, Priority = model.Priority, diff --git a/src/Lidarr.Api.V1/System/Tasks/TaskModule.cs b/src/Lidarr.Api.V1/System/Tasks/TaskModule.cs index cf1a38fb1..819d341fc 100644 --- a/src/Lidarr.Api.V1/System/Tasks/TaskModule.cs +++ b/src/Lidarr.Api.V1/System/Tasks/TaskModule.cs @@ -1,6 +1,7 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Jobs; using NzbDrone.Core.Messaging.Events; @@ -13,8 +14,6 @@ namespace Lidarr.Api.V1.System.Tasks { private readonly ITaskManager _taskManager; - private static readonly Regex NameRegex = new Regex("(? " " + match.Value), + Name = taskName.SplitCamelCase(), TaskName = taskName, Interval = scheduledTask.Interval, LastExecution = scheduledTask.LastExecution, diff --git a/src/Lidarr.Http/LidarrRestModuleWithSignalR.cs b/src/Lidarr.Http/LidarrRestModuleWithSignalR.cs index ea095fb0b..6ac1a41e5 100644 --- a/src/Lidarr.Http/LidarrRestModuleWithSignalR.cs +++ b/src/Lidarr.Http/LidarrRestModuleWithSignalR.cs @@ -46,20 +46,22 @@ namespace Lidarr.Http } } - protected void BroadcastResourceChange(ModelAction action, TResource resource) { - var signalRMessage = new SignalRMessage + if (GetType().Namespace.Contains("V1")) { - Name = Resource, - Body = new ResourceChangeMessage(resource, action), - Action = action - }; + var signalRMessage = new SignalRMessage + { + Name = Resource, + Body = new ResourceChangeMessage(resource, action), + Action = action + }; - _signalRBroadcaster.BroadcastMessage(signalRMessage); + _signalRBroadcaster.BroadcastMessage(signalRMessage); + } } - + protected void BroadcastResourceChange(ModelAction action) { if (GetType().Namespace.Contains("V1")) diff --git a/src/NzbDrone.Common/Extensions/StringExtensions.cs b/src/NzbDrone.Common/Extensions/StringExtensions.cs index c75c8ab2b..5eeed958d 100644 --- a/src/NzbDrone.Common/Extensions/StringExtensions.cs +++ b/src/NzbDrone.Common/Extensions/StringExtensions.cs @@ -9,6 +9,8 @@ namespace NzbDrone.Common.Extensions { public static class StringExtensions { + private static readonly Regex CamelCaseRegex = new Regex("(? " " + match.Value); + } + } } diff --git a/src/NzbDrone.Core/Messaging/Commands/CommandQueue.cs b/src/NzbDrone.Core/Messaging/Commands/CommandQueue.cs index d4e585f02..793f1eb3a 100644 --- a/src/NzbDrone.Core/Messaging/Commands/CommandQueue.cs +++ b/src/NzbDrone.Core/Messaging/Commands/CommandQueue.cs @@ -151,7 +151,7 @@ namespace NzbDrone.Core.Messaging.Commands // A command ready to execute else { - localItem.StartedAt = DateTime.Now; + localItem.StartedAt = DateTime.UtcNow; localItem.Status = CommandStatus.Started; item = localItem; diff --git a/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs b/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs index dc0e03462..590196dfa 100644 --- a/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs +++ b/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs @@ -17,6 +17,7 @@ namespace NzbDrone.Core.Messaging.Commands CommandModel Push(TCommand command, CommandPriority priority = CommandPriority.Normal, CommandTrigger trigger = CommandTrigger.Unspecified) where TCommand : Command; CommandModel Push(string commandName, DateTime? lastExecutionTime, CommandPriority priority = CommandPriority.Normal, CommandTrigger trigger = CommandTrigger.Unspecified); IEnumerable Queue(CancellationToken cancellationToken); + List All(); CommandModel Get(int id); List GetStarted(); void SetMessage(CommandModel command, string message); @@ -136,6 +137,12 @@ namespace NzbDrone.Core.Messaging.Commands return _commandQueue.GetConsumingEnumerable(cancellationToken); } + public List All() + { + _logger.Trace("Getting all commands"); + return _commandQueue.All(); + } + public CommandModel Get(int id) { var command = _commandQueue.Find(id);