New: Queued Task/Command List View

Co-Authored-By: Mark McDowall <markus101@users.noreply.github.com>
pull/469/head
Qstick 6 years ago
parent 9a1660da51
commit 60bb0ac063

@ -30,7 +30,7 @@ import TagSettings from 'Settings/Tags/TagSettings';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
import UISettingsConnector from 'Settings/UI/UISettingsConnector'; import UISettingsConnector from 'Settings/UI/UISettingsConnector';
import Status from 'System/Status/Status'; 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 BackupsConnector from 'System/Backup/BackupsConnector';
import UpdatesConnector from 'System/Updates/UpdatesConnector'; import UpdatesConnector from 'System/Updates/UpdatesConnector';
import LogsTableConnector from 'System/Events/LogsTableConnector'; import LogsTableConnector from 'System/Events/LogsTableConnector';
@ -218,7 +218,7 @@ function AppRoutes(props) {
<Route <Route
path="/system/tasks" path="/system/tasks"
component={TasksConnector} component={Tasks}
/> />
<Route <Route

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { findCommand } from 'Utilities/Command'; import { findCommand, isCommandExecuting } from 'Utilities/Command';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
@ -43,13 +43,21 @@ function createMapStateToProps() {
const previousArtist = sortedArtist[artistIndex - 1] || _.last(sortedArtist); const previousArtist = sortedArtist[artistIndex - 1] || _.last(sortedArtist);
const nextArtist = sortedArtist[artistIndex + 1] || _.first(sortedArtist); const nextArtist = sortedArtist[artistIndex + 1] || _.first(sortedArtist);
const isArtistRefreshing = !!findCommand(commands, { name: commandNames.REFRESH_ARTIST, artistId: artist.id }); const isArtistRefreshing = isCommandExecuting(findCommand(commands, { name: commandNames.REFRESH_ARTIST, artistId: artist.id }));
const allArtistRefreshing = _.some(commands, (command) => command.name === commandNames.REFRESH_ARTIST && !command.body.artistId); const artistRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_ARTIST });
const allArtistRefreshing = (
isCommandExecuting(artistRefreshingCommand) &&
!artistRefreshingCommand.body.artistId
);
const isRefreshing = isArtistRefreshing || allArtistRefreshing; const isRefreshing = isArtistRefreshing || allArtistRefreshing;
const isSearching = !!findCommand(commands, { name: commandNames.ARTIST_SEARCH, artistId: artist.id }); const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.ARTIST_SEARCH, artistId: artist.id }));
const isRenamingFiles = !!findCommand(commands, { name: commandNames.RENAME_FILES, artistId: artist.id }); const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, artistId: artist.id }));
const isRenamingArtistCommand = findCommand(commands, { name: commandNames.RENAME_ARTIST }); 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 isFetching = albums.isFetching || trackFiles.isFetching;
const isPopulated = albums.isPopulated && trackFiles.isPopulated; const isPopulated = albums.isPopulated && trackFiles.isPopulated;

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { findCommand } from 'Utilities/Command'; import { findCommand, isCommandExecuting } from 'Utilities/Command';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createArtistSelector from 'Store/Selectors/createArtistSelector'; import createArtistSelector from 'Store/Selectors/createArtistSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector';

@ -3,7 +3,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; 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 { setArtistEditorSort, setArtistEditorFilter, saveArtistEditor } from 'Store/Actions/artistEditorActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
@ -15,7 +15,7 @@ function createMapStateToProps() {
(state) => state.settings.languageProfiles, (state) => state.settings.languageProfiles,
(state) => state.settings.metadataProfiles, (state) => state.settings.metadataProfiles,
createClientSideCollectionSelector('artist', 'artistEditor'), createClientSideCollectionSelector('artist', 'artistEditor'),
createCommandSelector(commandNames.RENAME_ARTIST), createCommandExecutingSelector(commandNames.RENAME_ARTIST),
(languageProfiles, metadataProfiles, artist, isOrganizingArtist) => { (languageProfiles, metadataProfiles, artist, isOrganizingArtist) => {
return { return {
isOrganizingArtist, isOrganizingArtist,

@ -5,7 +5,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import dimensions from 'Styles/Variables/dimensions'; 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 createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { fetchArtist } from 'Store/Actions/artistActions'; import { fetchArtist } from 'Store/Actions/artistActions';
import scrollPositions from 'Store/scrollPositions'; import scrollPositions from 'Store/scrollPositions';
@ -47,8 +47,8 @@ function getScrollTop(view, scrollTop, isSmallScreen) {
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
createClientSideCollectionSelector('artist', 'artistIndex'), createClientSideCollectionSelector('artist', 'artistIndex'),
createCommandSelector(commandNames.REFRESH_ARTIST), createCommandExecutingSelector(commandNames.REFRESH_ARTIST),
createCommandSelector(commandNames.RSS_SYNC), createCommandExecutingSelector(commandNames.RSS_SYNC),
createDimensionsSelector(), createDimensionsSelector(),
( (
artist, artist,

@ -6,7 +6,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { repopulatePage } from 'Utilities/pagePopulator'; import { repopulatePage } from 'Utilities/pagePopulator';
import titleCase from 'Utilities/String/titleCase'; 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 { setAppValue, setVersion } from 'Store/Actions/appActions';
import { update, updateItem, removeItem } from 'Store/Actions/baseActions'; import { update, updateItem, removeItem } from 'Store/Actions/baseActions';
import { fetchHealth } from 'Store/Actions/systemActions'; import { fetchHealth } from 'Store/Actions/systemActions';
@ -58,16 +58,17 @@ function createMapStateToProps() {
} }
const mapDispatchToProps = { const mapDispatchToProps = {
updateCommand, dispatchFetchCommands: fetchCommands,
finishCommand, dispatchUpdateCommand: updateCommand,
setAppValue, dispatchFinishCommand: finishCommand,
setVersion, dispatchSetAppValue: setAppValue,
update, dispatchSetVersion: setVersion,
updateItem, dispatchUpdate: update,
removeItem, dispatchUpdateItem: updateItem,
fetchHealth, dispatchRemoveItem: removeItem,
fetchQueue, dispatchFetchHealth: fetchHealth,
fetchQueueDetails dispatchFetchQueue: fetchQueue,
dispatchFetchQueueDetails: fetchQueueDetails
}; };
class SignalRConnector extends Component { class SignalRConnector extends Component {
@ -146,7 +147,7 @@ class SignalRConnector extends Component {
handleCalendar = (body) => { handleCalendar = (body) => {
if (body.action === 'updated') { if (body.action === 'updated') {
this.props.updateItem({ this.props.dispatchUpdateItem({
section: 'calendar', section: 'calendar',
updateOnly: true, updateOnly: true,
...body.resource ...body.resource
@ -155,22 +156,27 @@ class SignalRConnector extends Component {
} }
handleCommand = (body) => { handleCommand = (body) => {
if (body.action === 'sync') {
this.props.dispatchFetchCommands();
return;
}
const resource = body.resource; const resource = body.resource;
const state = resource.state; const status = resource.status;
// Both sucessful and failed commands need to be // Both sucessful and failed commands need to be
// completed, otherwise they spin until they timeout. // completed, otherwise they spin until they timeout.
if (state === 'completed' || state === 'failed') { if (status === 'completed' || status === 'failed') {
this.props.finishCommand(resource); this.props.dispatchFinishCommand(resource);
} else { } else {
this.props.updateCommand(resource); this.props.dispatchUpdateCommand(resource);
} }
} }
handleAlbum = (body) => { handleAlbum = (body) => {
if (body.action === 'updated') { if (body.action === 'updated') {
this.props.updateItem({ this.props.dispatchUpdateItem({
section: 'albums', section: 'albums',
updateOnly: true, updateOnly: true,
...body.resource ...body.resource
@ -180,7 +186,7 @@ class SignalRConnector extends Component {
handleTrack = (body) => { handleTrack = (body) => {
if (body.action === 'updated') { if (body.action === 'updated') {
this.props.updateItem({ this.props.dispatchUpdateItem({
section: 'tracks', section: 'tracks',
updateOnly: true, updateOnly: true,
...body.resource ...body.resource
@ -192,14 +198,14 @@ class SignalRConnector extends Component {
const section = 'trackFiles'; const section = 'trackFiles';
if (body.action === 'updated') { if (body.action === 'updated') {
this.props.updateItem({ section, ...body.resource }); this.props.dispatchUpdateItem({ section, ...body.resource });
} else if (body.action === 'deleted') { } else if (body.action === 'deleted') {
this.props.removeItem({ section, id: body.resource.id }); this.props.dispatchRemoveItem({ section, id: body.resource.id });
} }
} }
handleHealth = () => { handleHealth = () => {
this.props.fetchHealth(); this.props.dispatchFetchHealth();
} }
handleArtist = (body) => { handleArtist = (body) => {
@ -207,35 +213,35 @@ class SignalRConnector extends Component {
const section = 'artist'; const section = 'artist';
if (action === 'updated') { if (action === 'updated') {
this.props.updateItem({ section, ...body.resource }); this.props.dispatchUpdateItem({ section, ...body.resource });
} else if (action === 'deleted') { } else if (action === 'deleted') {
this.props.removeItem({ section, id: body.resource.id }); this.props.dispatchRemoveItem({ section, id: body.resource.id });
} }
} }
handleQueue = () => { handleQueue = () => {
if (this.props.isQueuePopulated) { if (this.props.isQueuePopulated) {
this.props.fetchQueue(); this.props.dispatchFetchQueue();
} }
} }
handleQueueDetails = () => { handleQueueDetails = () => {
this.props.fetchQueueDetails(); this.props.dispatchFetchQueueDetails();
} }
handleQueueStatus = (body) => { handleQueueStatus = (body) => {
this.props.update({ section: 'queue.status', data: body.resource }); this.props.dispatchUpdate({ section: 'queue.status', data: body.resource });
} }
handleVersion = (body) => { handleVersion = (body) => {
const version = body.Version; const version = body.Version;
this.props.setVersion({ version }); this.props.dispatchSetVersion({ version });
} }
handleWantedCutoff = (body) => { handleWantedCutoff = (body) => {
if (body.action === 'updated') { if (body.action === 'updated') {
this.props.updateItem({ this.props.dispatchUpdateItem({
section: 'cutoffUnmet', section: 'cutoffUnmet',
updateOnly: true, updateOnly: true,
...body.resource ...body.resource
@ -245,7 +251,7 @@ class SignalRConnector extends Component {
handleWantedMissing = (body) => { handleWantedMissing = (body) => {
if (body.action === 'updated') { if (body.action === 'updated') {
this.props.updateItem({ this.props.dispatchUpdateItem({
section: 'missing', section: 'missing',
updateOnly: true, updateOnly: true,
...body.resource ...body.resource
@ -268,14 +274,20 @@ class SignalRConnector extends Component {
// Clear disconnected time // Clear disconnected time
this.disconnectedTime = null; this.disconnectedTime = null;
const {
dispatchFetchCommands,
dispatchSetAppValue
} = this.props;
// Repopulate the page (if a repopulator is set) to ensure things // Repopulate the page (if a repopulator is set) to ensure things
// are in sync after reconnecting. // are in sync after reconnecting.
if (this.props.isReconnecting || this.props.isDisconnected) { if (this.props.isReconnecting || this.props.isDisconnected) {
dispatchFetchCommands();
repopulatePage(); repopulatePage();
} }
this.props.setAppValue({ dispatchSetAppValue({
isConnected: true, isConnected: true,
isReconnecting: false, isReconnecting: false,
isDisconnected: false, isDisconnected: false,
@ -305,7 +317,7 @@ class SignalRConnector extends Component {
this.disconnectedTime = Math.floor(new Date().getTime() / 1000); this.disconnectedTime = Math.floor(new Date().getTime() / 1000);
} }
this.props.setAppValue({ this.props.dispatchSetAppValue({
isReconnecting: true isReconnecting: true
}); });
} }
@ -319,7 +331,7 @@ class SignalRConnector extends Component {
this.disconnectedTime = Math.floor(new Date().getTime() / 1000); this.disconnectedTime = Math.floor(new Date().getTime() / 1000);
} }
this.props.setAppValue({ this.props.dispatchSetAppValue({
isConnected: false, isConnected: false,
isReconnecting: true, isReconnecting: true,
isDisconnected: isAppDisconnected(this.disconnectedTime) isDisconnected: isAppDisconnected(this.disconnectedTime)
@ -340,16 +352,17 @@ SignalRConnector.propTypes = {
isReconnecting: PropTypes.bool.isRequired, isReconnecting: PropTypes.bool.isRequired,
isDisconnected: PropTypes.bool.isRequired, isDisconnected: PropTypes.bool.isRequired,
isQueuePopulated: PropTypes.bool.isRequired, isQueuePopulated: PropTypes.bool.isRequired,
updateCommand: PropTypes.func.isRequired, dispatchFetchCommands: PropTypes.func.isRequired,
finishCommand: PropTypes.func.isRequired, dispatchUpdateCommand: PropTypes.func.isRequired,
setAppValue: PropTypes.func.isRequired, dispatchFinishCommand: PropTypes.func.isRequired,
setVersion: PropTypes.func.isRequired, dispatchSetAppValue: PropTypes.func.isRequired,
update: PropTypes.func.isRequired, dispatchSetVersion: PropTypes.func.isRequired,
updateItem: PropTypes.func.isRequired, dispatchUpdate: PropTypes.func.isRequired,
removeItem: PropTypes.func.isRequired, dispatchUpdateItem: PropTypes.func.isRequired,
fetchHealth: PropTypes.func.isRequired, dispatchRemoveItem: PropTypes.func.isRequired,
fetchQueue: PropTypes.func.isRequired, dispatchFetchHealth: PropTypes.func.isRequired,
fetchQueueDetails: PropTypes.func.isRequired dispatchFetchQueue: PropTypes.func.isRequired,
dispatchFetchQueueDetails: PropTypes.func.isRequired
}; };
export default connect(createMapStateToProps, mapDispatchToProps)(SignalRConnector); export default connect(createMapStateToProps, mapDispatchToProps)(SignalRConnector);

@ -7,6 +7,7 @@ import { messageTypes } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks'; import { createThunk, handleThunks } from 'Store/thunks';
import createFetchHandler from './Creators/createFetchHandler'; import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions'; import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import { showMessage, hideMessage } from './appActions'; import { showMessage, hideMessage } from './appActions';
import { updateItem } from './baseActions'; import { updateItem } from './baseActions';
@ -35,6 +36,7 @@ export const defaultState = {
export const FETCH_COMMANDS = 'commands/fetchCommands'; export const FETCH_COMMANDS = 'commands/fetchCommands';
export const EXECUTE_COMMAND = 'commands/executeCommand'; export const EXECUTE_COMMAND = 'commands/executeCommand';
export const CANCEL_COMMAND = 'commands/cancelCommand';
export const ADD_COMMAND = 'commands/updateCommand'; export const ADD_COMMAND = 'commands/updateCommand';
export const UPDATE_COMMAND = 'commands/finishCommand'; export const UPDATE_COMMAND = 'commands/finishCommand';
export const FINISH_COMMAND = 'commands/addCommand'; export const FINISH_COMMAND = 'commands/addCommand';
@ -45,6 +47,7 @@ export const REMOVE_COMMAND = 'commands/removeCommand';
export const fetchCommands = createThunk(FETCH_COMMANDS); export const fetchCommands = createThunk(FETCH_COMMANDS);
export const executeCommand = createThunk(EXECUTE_COMMAND); export const executeCommand = createThunk(EXECUTE_COMMAND);
export const cancelCommand = createThunk(CANCEL_COMMAND);
export const updateCommand = createThunk(UPDATE_COMMAND); export const updateCommand = createThunk(UPDATE_COMMAND);
export const finishCommand = createThunk(FINISH_COMMAND); export const finishCommand = createThunk(FINISH_COMMAND);
export const addCommand = createAction(ADD_COMMAND); export const addCommand = createAction(ADD_COMMAND);
@ -60,7 +63,7 @@ function showCommandMessage(payload, dispatch) {
trigger, trigger,
message, message,
body = {}, body = {},
state status
} = payload; } = payload;
const { const {
@ -75,10 +78,10 @@ function showCommandMessage(payload, dispatch) {
let type = messageTypes.INFO; let type = messageTypes.INFO;
let hideAfter = 0; let hideAfter = 0;
if (state === 'completed') { if (status === 'completed') {
type = messageTypes.SUCCESS; type = messageTypes.SUCCESS;
hideAfter = 4; hideAfter = 4;
} else if (state === 'failed') { } else if (status === 'failed') {
type = messageTypes.ERROR; type = messageTypes.ERROR;
hideAfter = trigger === 'manual' ? 10 : 4; hideAfter = trigger === 'manual' ? 10 : 4;
} }
@ -95,8 +98,7 @@ function showCommandMessage(payload, dispatch) {
function scheduleRemoveCommand(command, dispatch) { function scheduleRemoveCommand(command, dispatch) {
const { const {
id, id,
status, status
body
} = command; } = command;
if (status === 'queued') { if (status === 'queued') {
@ -109,12 +111,6 @@ function scheduleRemoveCommand(command, dispatch) {
clearTimeout(timeoutId); 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(() => { removeCommandTimeoutIds[id] = setTimeout(() => {
dispatch(batchActions([ dispatch(batchActions([
removeCommand({ section: 'commands', id }), removeCommand({ section: 'commands', id }),
@ -122,7 +118,7 @@ function scheduleRemoveCommand(command, dispatch) {
])); ]));
delete removeCommandTimeoutIds[id]; 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) { [UPDATE_COMMAND]: function(getState, payload, dispatch) {
dispatch(updateItem({ section: 'commands', ...payload })); dispatch(updateItem({ section: 'commands', ...payload }));
@ -178,7 +176,7 @@ export const actionHandlers = handleThunks({
} }
}); });
dispatch(removeCommand({ section: 'commands', ...payload })); scheduleRemoveCommand(payload, dispatch);
showCommandMessage(payload, dispatch); showCommandMessage(payload, dispatch);
} }

@ -1,12 +1,12 @@
import _ from 'lodash';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { findCommand, isCommandExecuting } from 'Utilities/Command';
import createCommandsSelector from './createCommandsSelector'; import createCommandsSelector from './createCommandsSelector';
function createCommandExecutingSelector(name) { function createCommandExecutingSelector(name, contraints = {}) {
return createSelector( return createSelector(
createCommandsSelector(), createCommandsSelector(),
(commands) => { (commands) => {
return _.some(commands, { name }); return isCommandExecuting(findCommand(commands, { name, ...contraints }));
} }
); );
} }

@ -1,12 +1,12 @@
import _ from 'lodash';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { findCommand } from 'Utilities/Command';
import createCommandsSelector from './createCommandsSelector'; import createCommandsSelector from './createCommandsSelector';
function createCommandSelector(name, contraints = {}) { function createCommandSelector(name, contraints = {}) {
return createSelector( return createSelector(
createCommandsSelector(), createCommandsSelector(),
(commands) => { (commands) => {
return _.some(commands, { name, ...contraints }); return !!findCommand(commands, { name, ...contraints });
} }
); );
} }

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

@ -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 (
<TableRow>
<TableRowCell className={styles.trigger}>
<span className={styles.triggerContent}>
<Icon
name={triggerIcon}
title={titleCase(trigger)}
/>
<Icon
{...getStatusIconProps(status, message)}
/>
</span>
</TableRowCell>
<TableRowCell>{commandName}</TableRowCell>
<TableRowCell
className={styles.queued}
title={formatDateTime(queued, longDateFormat, timeFormat)}
>
{queuedAt}
</TableRowCell>
<TableRowCell
className={styles.started}
title={formatDateTime(started, longDateFormat, timeFormat)}
>
{startedAt}
</TableRowCell>
<TableRowCell
className={styles.ended}
title={formatDateTime(ended, longDateFormat, timeFormat)}
>
{endedAt}
</TableRowCell>
<TableRowCell className={styles.duration}>
{formatTimeSpan(duration)}
</TableRowCell>
<TableRowCell
className={styles.actions}
>
{
status === 'queued' &&
<IconButton
name={icons.REMOVE}
onPress={this.onCancelPress}
/>
}
</TableRowCell>
<ConfirmModal
isOpen={isCancelConfirmModalOpen}
kind={kinds.DANGER}
title="Cancel"
message={'Are you sure you want to cancel this pending task?'}
confirmLabel="Yes, Cancel"
cancelLabel="No, Leave It"
onConfirm={onCancelPress}
onCancel={this.onAbortCancel}
/>
</TableRow>
);
}
}
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;

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

@ -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 (
<FieldSet legend="Queue">
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
isPopulated &&
<Table
columns={columns}
>
<TableBody>
{
items.map((item) => {
return (
<QueuedTaskRowConnector
key={item.id}
{...item}
/>
);
})
}
</TableBody>
</Table>
}
</FieldSet>
);
}
QueuedTasks.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
items: PropTypes.array.isRequired
};
export default QueuedTasks;

@ -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 (
<QueuedTasks
{...this.props}
/>
);
}
}
QueuedTasksConnector.propTypes = {
dispatchFetchCommands: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueuedTasksConnector);

@ -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 (
<TableRow>
<TableRowCell>{name}</TableRowCell>
<TableRowCell
className={styles.interval}
>
{isDisabled ? 'disabled' : duration}
</TableRowCell>
<TableRowCell
className={styles.lastExecution}
title={formatDateTime(lastExecution, longDateFormat, timeFormat)}
>
{lastExecutionTime}
</TableRowCell>
{
isDisabled &&
<TableRowCell className={styles.nextExecution}>-</TableRowCell>
}
{
executeNow && isQueued &&
<TableRowCell className={styles.nextExecution}>queued</TableRowCell>
}
{
executeNow && !isQueued &&
<TableRowCell className={styles.nextExecution}>now</TableRowCell>
}
{
hasNextExecutionTime &&
<TableRowCell
className={styles.nextExecution}
title={formatDateTime(nextExecution, longDateFormat, timeFormat, { includeSeconds: true })}
>
{nextExecutionTime}
</TableRowCell>
}
<TableRowCell
className={styles.actions}
>
<SpinnerIconButton
name={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isExecuting}
onPress={onExecutePress}
/>
</TableRowCell>
</TableRow>
);
}
}
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;

@ -2,12 +2,12 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { findCommand } from 'Utilities/Command'; import { findCommand, isCommandExecuting } from 'Utilities/Command';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { fetchTask } from 'Store/Actions/systemActions'; import { fetchTask } from 'Store/Actions/systemActions';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import TaskRow from './TaskRow'; import ScheduledTaskRow from './ScheduledTaskRow';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
@ -15,10 +15,11 @@ function createMapStateToProps() {
createCommandsSelector(), createCommandsSelector(),
createUISettingsSelector(), createUISettingsSelector(),
(taskName, commands, uiSettings) => { (taskName, commands, uiSettings) => {
const isExecuting = !!findCommand(commands, { name: taskName }); const command = findCommand(commands, { name: taskName });
return { return {
isExecuting, isQueued: !!(command && command.state === 'queued'),
isExecuting: isCommandExecuting(command),
showRelativeDates: uiSettings.showRelativeDates, showRelativeDates: uiSettings.showRelativeDates,
shortDateFormat: uiSettings.shortDateFormat, shortDateFormat: uiSettings.shortDateFormat,
longDateFormat: uiSettings.longDateFormat, longDateFormat: uiSettings.longDateFormat,
@ -46,7 +47,7 @@ function createMapDispatchToProps(dispatch, props) {
}; };
} }
class TaskRowConnector extends Component { class ScheduledTaskRowConnector extends Component {
// //
// Lifecycle // Lifecycle
@ -75,17 +76,17 @@ class TaskRowConnector extends Component {
} = this.props; } = this.props;
return ( return (
<TaskRow <ScheduledTaskRow
{...otherProps} {...otherProps}
/> />
); );
} }
} }
TaskRowConnector.propTypes = { ScheduledTaskRowConnector.propTypes = {
id: PropTypes.number.isRequired, id: PropTypes.number.isRequired,
isExecuting: PropTypes.bool.isRequired, isExecuting: PropTypes.bool.isRequired,
dispatchFetchTask: PropTypes.func.isRequired dispatchFetchTask: PropTypes.func.isRequired
}; };
export default connect(createMapStateToProps, createMapDispatchToProps)(TaskRowConnector); export default connect(createMapStateToProps, createMapDispatchToProps)(ScheduledTaskRowConnector);

@ -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 (
<FieldSet legend="Scheduled">
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
isPopulated &&
<Table
columns={columns}
>
<TableBody>
{
items.map((item) => {
return (
<ScheduledTaskRowConnector
key={item.id}
{...item}
/>
);
})
}
</TableBody>
</Table>
}
</FieldSet>
);
}
ScheduledTasks.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
items: PropTypes.array.isRequired
};
export default ScheduledTasks;

@ -3,7 +3,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { fetchTasks } from 'Store/Actions/systemActions'; import { fetchTasks } from 'Store/Actions/systemActions';
import Tasks from './Tasks'; import ScheduledTasks from './ScheduledTasks';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
@ -15,16 +15,16 @@ function createMapStateToProps() {
} }
const mapDispatchToProps = { const mapDispatchToProps = {
fetchTasks dispatchFetchTasks: fetchTasks
}; };
class TasksConnector extends Component { class ScheduledTasksConnector extends Component {
// //
// Lifecycle // Lifecycle
componentDidMount() { componentDidMount() {
this.props.fetchTasks(); this.props.dispatchFetchTasks();
} }
// //
@ -32,15 +32,15 @@ class TasksConnector extends Component {
render() { render() {
return ( return (
<Tasks <ScheduledTasks
{...this.props} {...this.props}
/> />
); );
} }
} }
TasksConnector.propTypes = { ScheduledTasksConnector.propTypes = {
fetchTasks: PropTypes.func.isRequired dispatchFetchTasks: PropTypes.func.isRequired
}; };
export default connect(createMapStateToProps, mapDispatchToProps)(TasksConnector); export default connect(createMapStateToProps, mapDispatchToProps)(ScheduledTasksConnector);

@ -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 (
<TableRow>
<TableRowCell>{name}</TableRowCell>
<TableRowCell
className={styles.interval}
>
{disabled ? 'disabled' : duration}
</TableRowCell>
<TableRowCell
className={styles.lastExecution}
title={formatDateTime(lastExecution, longDateFormat, timeFormat)}
>
{showRelativeDates ? moment(lastExecution).fromNow() : formatDate(lastExecution, shortDateFormat)}
</TableRowCell>
{
disabled &&
<TableRowCell className={styles.nextExecution}>-</TableRowCell>
}
{
executeNow &&
<TableRowCell className={styles.nextExecution}>now</TableRowCell>
}
{
hasNextExecutionTime &&
<TableRowCell
className={styles.nextExecution}
title={formatDateTime(nextExecution, longDateFormat, timeFormat, { includeSeconds: true })}
>
{showRelativeDates ? moment(nextExecution).fromNow() : formatDate(nextExecution, shortDateFormat)}
</TableRowCell>
}
<TableRowCell
className={styles.actions}
>
<SpinnerIconButton
name={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isExecuting}
onPress={onExecutePress}
/>
</TableRowCell>
</TableRow>
);
}
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;

@ -1,89 +1,18 @@
import PropTypes from 'prop-types'; import React from 'react';
import React, { Component } from 'react';
import PageContent from 'Components/Page/PageContent'; import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector';
import Table from 'Components/Table/Table'; import QueuedTasksConnector from './Queued/QueuedTasksConnector';
import TableBody from 'Components/Table/TableBody';
import TaskRowConnector from './TaskRowConnector'; function Tasks() {
return (
const columns = [ <PageContent title="Tasks">
{ <PageContentBodyConnector>
name: 'name', <ScheduledTasksConnector />
label: 'Name', <QueuedTasksConnector />
isVisible: true </PageContentBodyConnector>
}, </PageContent>
{ );
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 (
<PageContent title="Tasks">
<PageContentBodyConnector>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
isPopulated &&
<Table
columns={columns}
>
<TableBody>
{
items.map((item) => {
return (
<TaskRowConnector
key={item.id}
{...item}
/>
);
})
}
</TableBody>
</Table>
}
</PageContentBodyConnector>
</PageContent>
);
}
} }
Tasks.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
items: PropTypes.array.isRequired
};
export default Tasks; export default Tasks;

@ -3,7 +3,7 @@ function isCommandComplete(command) {
return false; return false;
} }
return command.state === 'complete'; return command.status === 'complete';
} }
export default isCommandComplete; export default isCommandComplete;

@ -3,7 +3,7 @@ function isCommandExecuting(command) {
return false; return false;
} }
return command.state === 'queued' || command.state === 'started'; return command.status === 'queued' || command.status === 'started';
} }
export default isCommandExecuting; export default isCommandExecuting;

@ -3,10 +3,10 @@ function isCommandFailed(command) {
return false; return false;
} }
return command.state === 'failed' || return command.status === 'failed' ||
command.state === 'aborted' || command.status === 'aborted' ||
command.state === 'cancelled' || command.status === 'cancelled' ||
command.state === 'orphaned'; command.status === 'orphaned';
} }
export default isCommandFailed; export default isCommandFailed;

@ -32,6 +32,7 @@ namespace Lidarr.Api.V1.Commands
GetResourceById = GetCommand; GetResourceById = GetCommand;
CreateResource = StartCommand; CreateResource = StartCommand;
GetResourceAll = GetStartedCommands; GetResourceAll = GetStartedCommands;
DeleteResource = CancelCommand;
PostValidator.RuleFor(c => c.Name).NotBlank(); PostValidator.RuleFor(c => c.Name).NotBlank();
@ -62,7 +63,13 @@ namespace Lidarr.Api.V1.Commands
private List<CommandResource> GetStartedCommands() private List<CommandResource> 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) public void Handle(CommandUpdatedEvent message)
@ -75,6 +82,13 @@ namespace Lidarr.Api.V1.Commands
} }
_debouncer.Execute(); _debouncer.Execute();
} }
if (message.Command.Name == typeof(MessagingCleanupCommand).Name.Replace("Command", "") &&
message.Command.Status == CommandStatus.Completed)
{
BroadcastResourceChange(ModelAction.Sync);
}
} }
private void SendUpdates() private void SendUpdates()

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Newtonsoft.Json; using Newtonsoft.Json;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Commands;
using Lidarr.Http.REST; using Lidarr.Http.REST;
@ -10,6 +11,7 @@ namespace Lidarr.Api.V1.Commands
public class CommandResource : RestResource public class CommandResource : RestResource
{ {
public string Name { get; set; } public string Name { get; set; }
public string CommandName { get; set; }
public string Message { get; set; } public string Message { get; set; }
public Command Body { get; set; } public Command Body { get; set; }
public CommandPriority Priority { get; set; } public CommandPriority Priority { get; set; }
@ -75,6 +77,7 @@ namespace Lidarr.Api.V1.Commands
Id = model.Id, Id = model.Id,
Name = model.Name, Name = model.Name,
CommandName = model.Name.SplitCamelCase(),
Message = model.Message, Message = model.Message,
Body = model.Body, Body = model.Body,
Priority = model.Priority, Priority = model.Priority,

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.Jobs; using NzbDrone.Core.Jobs;
using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Messaging.Events;
@ -13,8 +14,6 @@ namespace Lidarr.Api.V1.System.Tasks
{ {
private readonly ITaskManager _taskManager; private readonly ITaskManager _taskManager;
private static readonly Regex NameRegex = new Regex("(?<!^)[A-Z]", RegexOptions.Compiled);
public TaskModule(ITaskManager taskManager, IBroadcastSignalRMessage broadcastSignalRMessage) public TaskModule(ITaskManager taskManager, IBroadcastSignalRMessage broadcastSignalRMessage)
: base(broadcastSignalRMessage, "system/task") : base(broadcastSignalRMessage, "system/task")
{ {
@ -51,7 +50,7 @@ namespace Lidarr.Api.V1.System.Tasks
return new TaskResource return new TaskResource
{ {
Id = scheduledTask.Id, Id = scheduledTask.Id,
Name = NameRegex.Replace(taskName, match => " " + match.Value), Name = taskName.SplitCamelCase(),
TaskName = taskName, TaskName = taskName,
Interval = scheduledTask.Interval, Interval = scheduledTask.Interval,
LastExecution = scheduledTask.LastExecution, LastExecution = scheduledTask.LastExecution,

@ -46,17 +46,19 @@ namespace Lidarr.Http
} }
} }
protected void BroadcastResourceChange(ModelAction action, TResource resource) protected void BroadcastResourceChange(ModelAction action, TResource resource)
{ {
var signalRMessage = new SignalRMessage if (GetType().Namespace.Contains("V1"))
{ {
Name = Resource, var signalRMessage = new SignalRMessage
Body = new ResourceChangeMessage<TResource>(resource, action), {
Action = action Name = Resource,
}; Body = new ResourceChangeMessage<TResource>(resource, action),
Action = action
};
_signalRBroadcaster.BroadcastMessage(signalRMessage); _signalRBroadcaster.BroadcastMessage(signalRMessage);
}
} }

@ -9,6 +9,8 @@ namespace NzbDrone.Common.Extensions
{ {
public static class StringExtensions public static class StringExtensions
{ {
private static readonly Regex CamelCaseRegex = new Regex("(?<!^)[A-Z]", RegexOptions.Compiled);
public static string NullSafe(this string target) public static string NullSafe(this string target)
{ {
return ((object)target).NullSafe().ToString(); return ((object)target).NullSafe().ToString();
@ -133,5 +135,11 @@ namespace NzbDrone.Common.Extensions
return Encoding.ASCII.GetString(new [] { byteResult }); return Encoding.ASCII.GetString(new [] { byteResult });
} }
public static string SplitCamelCase(this string input)
{
return CamelCaseRegex.Replace(input, match => " " + match.Value);
}
} }
} }

@ -151,7 +151,7 @@ namespace NzbDrone.Core.Messaging.Commands
// A command ready to execute // A command ready to execute
else else
{ {
localItem.StartedAt = DateTime.Now; localItem.StartedAt = DateTime.UtcNow;
localItem.Status = CommandStatus.Started; localItem.Status = CommandStatus.Started;
item = localItem; item = localItem;

@ -17,6 +17,7 @@ namespace NzbDrone.Core.Messaging.Commands
CommandModel Push<TCommand>(TCommand command, CommandPriority priority = CommandPriority.Normal, CommandTrigger trigger = CommandTrigger.Unspecified) where TCommand : Command; CommandModel Push<TCommand>(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); CommandModel Push(string commandName, DateTime? lastExecutionTime, CommandPriority priority = CommandPriority.Normal, CommandTrigger trigger = CommandTrigger.Unspecified);
IEnumerable<CommandModel> Queue(CancellationToken cancellationToken); IEnumerable<CommandModel> Queue(CancellationToken cancellationToken);
List<CommandModel> All();
CommandModel Get(int id); CommandModel Get(int id);
List<CommandModel> GetStarted(); List<CommandModel> GetStarted();
void SetMessage(CommandModel command, string message); void SetMessage(CommandModel command, string message);
@ -136,6 +137,12 @@ namespace NzbDrone.Core.Messaging.Commands
return _commandQueue.GetConsumingEnumerable(cancellationToken); return _commandQueue.GetConsumingEnumerable(cancellationToken);
} }
public List<CommandModel> All()
{
_logger.Trace("Getting all commands");
return _commandQueue.All();
}
public CommandModel Get(int id) public CommandModel Get(int id)
{ {
var command = _commandQueue.Find(id); var command = _commandQueue.Find(id);

Loading…
Cancel
Save