mirror of https://github.com/lidarr/Lidarr
New: UI Updates (Backup Restore in App, Profile Cloning)
UI Pulls from Sonarr
This commit is contained in:
parent
80a5701b99
commit
744742b5ff
|
@ -1,4 +1,5 @@
|
|||
.card {
|
||||
position: relative;
|
||||
margin: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
|
@ -6,3 +7,13 @@
|
|||
box-shadow: 0 0 10px 1px $cardShadowColor;
|
||||
color: $defaultColor;
|
||||
}
|
||||
|
||||
.underlay {
|
||||
@add-mixin cover;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
@add-mixin linkOverlay;
|
||||
|
||||
position: relative;
|
||||
}
|
|
@ -11,10 +11,27 @@ class Card extends Component {
|
|||
render() {
|
||||
const {
|
||||
className,
|
||||
overlayClassName,
|
||||
overlayContent,
|
||||
children,
|
||||
onPress
|
||||
} = this.props;
|
||||
|
||||
if (overlayContent) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Link
|
||||
className={styles.underlay}
|
||||
onPress={onPress}
|
||||
/>
|
||||
|
||||
<div className={overlayClassName}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={className}
|
||||
|
@ -28,12 +45,16 @@ class Card extends Component {
|
|||
|
||||
Card.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
overlayClassName: PropTypes.string.isRequired,
|
||||
overlayContent: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
Card.defaultProps = {
|
||||
className: styles.card
|
||||
className: styles.card,
|
||||
overlayClassName: styles.overlay,
|
||||
overlayContent: false
|
||||
};
|
||||
|
||||
export default Card;
|
||||
|
|
|
@ -42,10 +42,8 @@ function CaptchaInput(props) {
|
|||
onPress={onRefreshPress}
|
||||
>
|
||||
<Icon
|
||||
name={classNames(
|
||||
icons.REFRESH,
|
||||
refreshing && 'fa-spin'
|
||||
)}
|
||||
name={icons.REFRESH}
|
||||
isSpinning={refreshing}
|
||||
/>
|
||||
</FormInputButton>
|
||||
</div>
|
||||
|
|
|
@ -5,14 +5,107 @@ import styles from './TextInput.css';
|
|||
|
||||
class TextInput extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._input = null;
|
||||
this._selectionStart = null;
|
||||
this._selectionEnd = null;
|
||||
this._selectionTimeout = null;
|
||||
this._isMouseTarget = false;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('mouseup', this.onDocumentMouseUp);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('mouseup', this.onDocumentMouseUp);
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
setInputRef = (ref) => {
|
||||
this._input = ref;
|
||||
}
|
||||
|
||||
selectionChange() {
|
||||
if (this._selectionTimeout) {
|
||||
this._selectionTimeout = clearTimeout(this._selectionTimeout);
|
||||
}
|
||||
|
||||
this._selectionTimeout = setTimeout(() => {
|
||||
const selectionStart = this._input.selectionStart;
|
||||
const selectionEnd = this._input.selectionEnd;
|
||||
|
||||
const selectionChanged = (
|
||||
this._selectionStart !== selectionStart ||
|
||||
this._selectionEnd !== selectionEnd
|
||||
);
|
||||
|
||||
this._selectionStart = selectionStart;
|
||||
this._selectionEnd = selectionEnd;
|
||||
|
||||
if (this.props.onSelectionChange && selectionChanged) {
|
||||
this.props.onSelectionChange(selectionStart, selectionEnd);
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onChange = (event) => {
|
||||
this.props.onChange({
|
||||
name: this.props.name,
|
||||
const {
|
||||
name,
|
||||
type,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
value: event.target.value
|
||||
});
|
||||
};
|
||||
|
||||
// Also return the files for a file input type.
|
||||
|
||||
if (type === 'file') {
|
||||
payload.files = event.target.files;
|
||||
}
|
||||
|
||||
onChange(payload);
|
||||
}
|
||||
|
||||
onFocus = (event) => {
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(event);
|
||||
}
|
||||
|
||||
this.selectionChange();
|
||||
}
|
||||
|
||||
onKeyUp = () => {
|
||||
this.selectionChange();
|
||||
}
|
||||
|
||||
onMouseDown = () => {
|
||||
this._isMouseTarget = true;
|
||||
}
|
||||
|
||||
onMouseUp = () => {
|
||||
this.selectionChange();
|
||||
}
|
||||
|
||||
onDocumentMouseUp = () => {
|
||||
if (this._isMouseTarget) {
|
||||
this.selectionChange();
|
||||
}
|
||||
|
||||
this._isMouseTarget = false;
|
||||
}
|
||||
|
||||
//
|
||||
|
@ -29,12 +122,12 @@ class TextInput extends Component {
|
|||
value,
|
||||
hasError,
|
||||
hasWarning,
|
||||
hasButton,
|
||||
onFocus
|
||||
hasButton
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={this.setInputRef}
|
||||
type={type}
|
||||
readOnly={readOnly}
|
||||
autoFocus={autoFocus}
|
||||
|
@ -49,7 +142,10 @@ class TextInput extends Component {
|
|||
name={name}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onFocus={onFocus}
|
||||
onFocus={this.onFocus}
|
||||
onKeyUp={this.onKeyUp}
|
||||
onMouseDown={this.onMouseDown}
|
||||
onMouseUp={this.onMouseUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -67,7 +163,8 @@ TextInput.propTypes = {
|
|||
hasWarning: PropTypes.bool,
|
||||
hasButton: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onFocus: PropTypes.func
|
||||
onFocus: PropTypes.func,
|
||||
onSelectionChange: PropTypes.func
|
||||
};
|
||||
|
||||
TextInput.defaultProps = {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import classNames from 'classnames';
|
||||
import styles from './Icon.css';
|
||||
|
||||
|
@ -10,7 +10,8 @@ function Icon(props) {
|
|||
name,
|
||||
kind,
|
||||
size,
|
||||
title
|
||||
title,
|
||||
isSpinning
|
||||
} = props;
|
||||
|
||||
return (
|
||||
|
@ -18,7 +19,8 @@ function Icon(props) {
|
|||
className={classNames(
|
||||
name,
|
||||
className,
|
||||
styles[kind]
|
||||
styles[kind],
|
||||
isSpinning && icons.SPIN
|
||||
)}
|
||||
title={title}
|
||||
style={{
|
||||
|
@ -33,12 +35,14 @@ Icon.propTypes = {
|
|||
name: PropTypes.string.isRequired,
|
||||
kind: PropTypes.string.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
title: PropTypes.string
|
||||
title: PropTypes.string,
|
||||
isSpinning: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
Icon.defaultProps = {
|
||||
kind: kinds.DEFAULT,
|
||||
size: 14
|
||||
size: 14,
|
||||
isSpinning: false
|
||||
};
|
||||
|
||||
export default Icon;
|
||||
|
|
|
@ -11,6 +11,7 @@ function IconButton(props) {
|
|||
name,
|
||||
kind,
|
||||
size,
|
||||
isSpinning,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
|
@ -24,6 +25,7 @@ function IconButton(props) {
|
|||
name={name}
|
||||
kind={kind}
|
||||
size={size}
|
||||
isSpinning={isSpinning}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
@ -34,7 +36,8 @@ IconButton.propTypes = {
|
|||
iconClassName: PropTypes.string,
|
||||
kind: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
size: PropTypes.number
|
||||
size: PropTypes.number,
|
||||
isSpinning: PropTypes.bool
|
||||
};
|
||||
|
||||
IconButton.defaultProps = {
|
||||
|
|
|
@ -29,10 +29,8 @@ function SpinnerButton(props) {
|
|||
<span className={styles.spinnerContainer}>
|
||||
<Icon
|
||||
className={styles.spinner}
|
||||
name={classNames(
|
||||
spinnerIcon,
|
||||
'fa-spin'
|
||||
)}
|
||||
name={spinnerIcon}
|
||||
isSpinning={true}
|
||||
/>
|
||||
</span>
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ function SpinnerIconButton(props) {
|
|||
|
||||
return (
|
||||
<IconButton
|
||||
name={isSpinning ? `${spinningName || name} fa-spin` : name}
|
||||
name={isSpinning ? (spinningName || name) : name}
|
||||
isDisabled={isDisabled || isSpinning}
|
||||
{...otherProps}
|
||||
/>
|
||||
|
|
|
@ -26,7 +26,8 @@ function PageToolbarButton(props) {
|
|||
{...otherProps}
|
||||
>
|
||||
<Icon
|
||||
name={isSpinning ? `${spinningName || iconName} fa-spin` : iconName}
|
||||
name={isSpinning ? (spinningName || iconName) : iconName}
|
||||
isSpinning={isSpinning}
|
||||
size={22}
|
||||
/>
|
||||
|
||||
|
|
|
@ -312,7 +312,8 @@ class SignalRConnector extends Component {
|
|||
this.props.setAppValue({
|
||||
isConnected: true,
|
||||
isReconnecting: false,
|
||||
isDisconnected: false
|
||||
isDisconnected: false,
|
||||
isRestarting: false
|
||||
});
|
||||
|
||||
this.retryInterval = 5;
|
||||
|
|
|
@ -13,7 +13,7 @@ function SpinnerIcon(props) {
|
|||
|
||||
return (
|
||||
<Icon
|
||||
name={isSpinning ? `${spinningName || name} fa-spin` : name}
|
||||
name={isSpinning ? (spinningName || name) : name}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -18,6 +18,7 @@ export const CIRCLE_OUTLINE = 'fa fa-circle-o';
|
|||
export const CLEAR = 'fa fa-trash';
|
||||
export const CLIPBOARD = 'fa fa-clipboard';
|
||||
export const CLOSE = 'fa fa-times';
|
||||
export const CLONE = 'fa fa-clone';
|
||||
export const COLLAPSE = 'fa fa-chevron-circle-up';
|
||||
export const COMPUTER = 'fa fa-desktop';
|
||||
export const DANGER = 'fa fa-exclamation-circle';
|
||||
|
@ -67,6 +68,7 @@ export const QUICK = 'fa fa-rocket';
|
|||
export const REFRESH = 'fa fa-refresh';
|
||||
export const REMOVE = 'fa fa-remove';
|
||||
export const RESTART = 'fa fa-repeat';
|
||||
export const RESTORE = 'fa fa-recycle';
|
||||
export const REORDER = 'fa fa-bars';
|
||||
export const RSS = 'fa fa-rss';
|
||||
export const SAVE = 'fa fa-floppy-o';
|
||||
|
|
|
@ -13,7 +13,7 @@ function createMapStateToProps() {
|
|||
(languageProfiles) => {
|
||||
const {
|
||||
isFetchingSchema: isFetching,
|
||||
schemaPopulated: isPopulated,
|
||||
isSchemaPopulated: isPopulated,
|
||||
schemaError: error,
|
||||
schema
|
||||
} = languageProfiles;
|
||||
|
|
|
@ -14,7 +14,7 @@ function createMapStateToProps() {
|
|||
(qualityProfiles) => {
|
||||
const {
|
||||
isFetchingSchema: isFetching,
|
||||
schemaPopulated: isPopulated,
|
||||
isSchemaPopulated: isPopulated,
|
||||
schemaError: error,
|
||||
schema
|
||||
} = qualityProfiles;
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { inputTypes, sizes } from 'Helpers/Props';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
|
||||
function AnalyticSettings(props) {
|
||||
const {
|
||||
settings,
|
||||
onInputChange
|
||||
} = props;
|
||||
|
||||
const {
|
||||
analyticsEnabled
|
||||
} = settings;
|
||||
|
||||
return (
|
||||
<FieldSet legend="Analytics">
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>Send Anonymous Usage Data</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="analyticsEnabled"
|
||||
helpText="Send anonymous usage and error information to Sonarr's servers. This includes information on your browser, which Lidarr WebUI pages you use, error reporting as well as OS and runtime version. We will use this information to prioritize features and bug fixes."
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...analyticsEnabled}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
AnalyticSettings.propTypes = {
|
||||
settings: PropTypes.object.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AnalyticSettings;
|
|
@ -0,0 +1,82 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
|
||||
function BackupSettings(props) {
|
||||
const {
|
||||
advancedSettings,
|
||||
settings,
|
||||
onInputChange
|
||||
} = props;
|
||||
|
||||
const {
|
||||
backupFolder,
|
||||
backupInterval,
|
||||
backupRetention
|
||||
} = settings;
|
||||
|
||||
if (!advancedSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldSet legend="Backups">
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>Folder</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="backupFolder"
|
||||
helpText="Relative paths will be under Lidarr's AppData directory"
|
||||
onChange={onInputChange}
|
||||
{...backupFolder}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>Interval</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="backupInterval"
|
||||
helpText="Interval in days"
|
||||
onChange={onInputChange}
|
||||
{...backupInterval}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>Retention</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="backupRetention"
|
||||
helpText="Retention in days. Automatic backups older the retention will be cleaned up automatically"
|
||||
onChange={onInputChange}
|
||||
{...backupRetention}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
BackupSettings.propTypes = {
|
||||
advancedSettings: PropTypes.bool.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default BackupSettings;
|
|
@ -1,20 +1,20 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Icon from 'Components/Icon';
|
||||
import ClipboardButton from 'Components/Link/ClipboardButton';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
|
||||
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import AnalyticSettings from './AnalyticSettings';
|
||||
import BackupSettings from './BackupSettings';
|
||||
import HostSettings from './HostSettings';
|
||||
import LoggingSettings from './LoggingSettings';
|
||||
import ProxySettings from './ProxySettings';
|
||||
import SecuritySettings from './SecuritySettings';
|
||||
import UpdateSettings from './UpdateSettings';
|
||||
|
||||
class GeneralSettings extends Component {
|
||||
|
||||
|
@ -25,7 +25,6 @@ class GeneralSettings extends Component {
|
|||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isConfirmApiKeyResetModalOpen: false,
|
||||
isRestartRequiredModalOpen: false
|
||||
};
|
||||
}
|
||||
|
@ -76,23 +75,6 @@ class GeneralSettings extends Component {
|
|||
//
|
||||
// Listeners
|
||||
|
||||
onApikeyFocus = (event) => {
|
||||
event.target.select();
|
||||
}
|
||||
|
||||
onResetApiKeyPress = () => {
|
||||
this.setState({ isConfirmApiKeyResetModalOpen: true });
|
||||
}
|
||||
|
||||
onConfirmResetApiKey = () => {
|
||||
this.setState({ isConfirmApiKeyResetModalOpen: false });
|
||||
this.props.onConfirmResetApiKey();
|
||||
}
|
||||
|
||||
onCloseResetApiKeyModal = () => {
|
||||
this.setState({ isConfirmApiKeyResetModalOpen: false });
|
||||
}
|
||||
|
||||
onConfirmRestart = () => {
|
||||
this.setState({ isRestartRequiredModalOpen: false });
|
||||
this.props.onConfirmRestart();
|
||||
|
@ -118,67 +100,10 @@ class GeneralSettings extends Component {
|
|||
isWindows,
|
||||
mode,
|
||||
onInputChange,
|
||||
onConfirmResetApiKey,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
isConfirmApiKeyResetModalOpen,
|
||||
isRestartRequiredModalOpen
|
||||
} = this.state;
|
||||
|
||||
const {
|
||||
bindAddress,
|
||||
port,
|
||||
urlBase,
|
||||
enableSsl,
|
||||
sslPort,
|
||||
sslCertHash,
|
||||
launchBrowser,
|
||||
authenticationMethod,
|
||||
username,
|
||||
password,
|
||||
apiKey,
|
||||
proxyEnabled,
|
||||
proxyType,
|
||||
proxyHostname,
|
||||
proxyPort,
|
||||
proxyUsername,
|
||||
proxyPassword,
|
||||
proxyBypassFilter,
|
||||
proxyBypassLocalAddresses,
|
||||
logLevel,
|
||||
analyticsEnabled,
|
||||
branch,
|
||||
updateAutomatically,
|
||||
updateMechanism,
|
||||
updateScriptPath
|
||||
} = settings;
|
||||
|
||||
const authenticationMethodOptions = [
|
||||
{ key: 'none', value: 'None' },
|
||||
{ key: 'basic', value: 'Basic (Browser Popup)' },
|
||||
{ key: 'forms', value: 'Forms (Login Page)' }
|
||||
];
|
||||
|
||||
const proxyTypeOptions = [
|
||||
{ key: 'http', value: 'HTTP(S)' },
|
||||
{ key: 'socks4', value: 'Socks4' },
|
||||
{ key: 'socks5', value: 'Socks5 (Support TOR)' }
|
||||
];
|
||||
|
||||
const logLevelOptions = [
|
||||
{ key: 'info', value: 'Info' },
|
||||
{ key: 'debug', value: 'Debug' },
|
||||
{ key: 'trace', value: 'Trace' }
|
||||
];
|
||||
|
||||
const updateOptions = [
|
||||
{ key: 'builtIn', value: 'Built-In' },
|
||||
{ key: 'script', value: 'Script' }
|
||||
];
|
||||
|
||||
const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none';
|
||||
|
||||
return (
|
||||
<PageContent title="General Settings">
|
||||
<SettingsToolbarConnector
|
||||
|
@ -202,425 +127,55 @@ class GeneralSettings extends Component {
|
|||
id="generalSettings"
|
||||
{...otherProps}
|
||||
>
|
||||
<FieldSet
|
||||
legend="Start-Up"
|
||||
>
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>Bind Address</FormLabel>
|
||||
<HostSettings
|
||||
advancedSettings={advancedSettings}
|
||||
settings={settings}
|
||||
isWindows={isWindows}
|
||||
mode={mode}
|
||||
onInputChange={onInputChange}
|
||||
/>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="bindAddress"
|
||||
helpText="Valid IP4 address or '*' for all interfaces"
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...bindAddress}
|
||||
/>
|
||||
</FormGroup>
|
||||
<SecuritySettings
|
||||
settings={settings}
|
||||
isResettingApiKey={isResettingApiKey}
|
||||
onInputChange={onInputChange}
|
||||
onConfirmResetApiKey={onConfirmResetApiKey}
|
||||
/>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Port Number</FormLabel>
|
||||
<ProxySettings
|
||||
settings={settings}
|
||||
onInputChange={onInputChange}
|
||||
/>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="port"
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...port}
|
||||
/>
|
||||
</FormGroup>
|
||||
<LoggingSettings
|
||||
settings={settings}
|
||||
onInputChange={onInputChange}
|
||||
/>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>URL Base</FormLabel>
|
||||
<AnalyticSettings
|
||||
settings={settings}
|
||||
onInputChange={onInputChange}
|
||||
/>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="urlBase"
|
||||
helpText="For reverse proxy support, default is empty"
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...urlBase}
|
||||
/>
|
||||
</FormGroup>
|
||||
<UpdateSettings
|
||||
advancedSettings={advancedSettings}
|
||||
settings={settings}
|
||||
isMono={isMono}
|
||||
onInputChange={onInputChange}
|
||||
/>
|
||||
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
size={sizes.MEDIUM}
|
||||
>
|
||||
<FormLabel>Enable SSL</FormLabel>
|
||||
<BackupSettings
|
||||
advancedSettings={advancedSettings}
|
||||
settings={settings}
|
||||
onInputChange={onInputChange}
|
||||
/>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="enableSsl"
|
||||
helpText=" Requires restart running as administrator to take effect"
|
||||
onChange={onInputChange}
|
||||
{...enableSsl}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
enableSsl.value &&
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>SSL Port</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="sslPort"
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...sslPort}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
|
||||
{
|
||||
isWindows && enableSsl.value &&
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>SSL Cert Hash</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="sslCertHash"
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...sslCertHash}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
|
||||
{
|
||||
mode !== 'service' &&
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>Open browser on start</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="launchBrowser"
|
||||
helpText=" Open a web browser and navigate to Lidarr homepage on app start."
|
||||
onChange={onInputChange}
|
||||
{...launchBrowser}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet
|
||||
legend="Security"
|
||||
>
|
||||
<FormGroup>
|
||||
<FormLabel>Authentication</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="authenticationMethod"
|
||||
values={authenticationMethodOptions}
|
||||
helpText="Require Username and Password to access Lidarr"
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...authenticationMethod}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
authenticationEnabled &&
|
||||
<FormGroup>
|
||||
<FormLabel>Username</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="username"
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...username}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
|
||||
{
|
||||
authenticationEnabled &&
|
||||
<FormGroup>
|
||||
<FormLabel>Password</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PASSWORD}
|
||||
name="password"
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...password}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>API Key</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="apiKey"
|
||||
readOnly={true}
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
buttons={[
|
||||
<ClipboardButton
|
||||
key="copy"
|
||||
value={apiKey.value}
|
||||
kind={kinds.DEFAULT}
|
||||
/>,
|
||||
|
||||
<FormInputButton
|
||||
key="reset"
|
||||
kind={kinds.DANGER}
|
||||
onPress={this.onResetApiKeyPress}
|
||||
>
|
||||
<Icon name={isResettingApiKey ? `${icons.REFRESH} fa-spin` : icons.REFRESH} />
|
||||
</FormInputButton>
|
||||
]}
|
||||
onChange={onInputChange}
|
||||
onFocus={this.onApikeyFocus}
|
||||
{...apiKey}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet
|
||||
legend="Proxy Settings"
|
||||
>
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>Use Proxy</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="proxyEnabled"
|
||||
onChange={onInputChange}
|
||||
{...proxyEnabled}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
proxyEnabled.value &&
|
||||
<div>
|
||||
<FormGroup>
|
||||
<FormLabel>Proxy Type</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="proxyType"
|
||||
values={proxyTypeOptions}
|
||||
onChange={onInputChange}
|
||||
{...proxyType}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Hostname</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="proxyHostname"
|
||||
onChange={onInputChange}
|
||||
{...proxyHostname}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Port</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="proxyPort"
|
||||
onChange={onInputChange}
|
||||
{...proxyPort}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Username</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="proxyUsername"
|
||||
helpText="You only need to enter a username and password if one is required. Leave them blank otherwise."
|
||||
onChange={onInputChange}
|
||||
{...proxyUsername}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Password</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PASSWORD}
|
||||
name="proxyPassword"
|
||||
helpText="You only need to enter a username and password if one is required. Leave them blank otherwise."
|
||||
onChange={onInputChange}
|
||||
{...proxyPassword}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Ignored Addresses</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="proxyBypassFilter"
|
||||
helpText="Use ',' as a separator, and '*.' as a wildcard for subdomains"
|
||||
onChange={onInputChange}
|
||||
{...proxyBypassFilter}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>Bypass Proxy for Local Addresses</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="proxyBypassLocalAddresses"
|
||||
onChange={onInputChange}
|
||||
{...proxyBypassLocalAddresses}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
}
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet
|
||||
legend="Logging"
|
||||
>
|
||||
<FormGroup>
|
||||
<FormLabel>Log Level</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="logLevel"
|
||||
values={logLevelOptions}
|
||||
helpTextWarning={logLevel.value === 'trace' ? 'Trace logging should only be enabled temporarily' : undefined}
|
||||
onChange={onInputChange}
|
||||
{...logLevel}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet
|
||||
legend="Analytics"
|
||||
>
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>Send Anonymous Usage Data</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="analyticsEnabled"
|
||||
helpText="Send anonymous usage and error information to Lidarr's servers. This includes information on your browser, which Lidarr WebUI pages you use, error reporting as well as OS and runtime version. We will use this information to prioritize features and bug fixes."
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...analyticsEnabled}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FieldSet>
|
||||
|
||||
{
|
||||
advancedSettings &&
|
||||
<FieldSet
|
||||
legend="Updates"
|
||||
>
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>Branch</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="branch"
|
||||
helpText="Branch to use to update Lidarr"
|
||||
helpLink="https://github.com/lidarr/Lidarr/wiki/Release-Branches"
|
||||
onChange={onInputChange}
|
||||
{...branch}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
isMono &&
|
||||
<div>
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
size={sizes.MEDIUM}
|
||||
>
|
||||
<FormLabel>Automatic</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="updateAutomatically"
|
||||
helpText="Automatically download and install updates. You will still be able to install from System: Updates"
|
||||
onChange={onInputChange}
|
||||
{...updateAutomatically}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>Mechanism</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="updateMechanism"
|
||||
values={updateOptions}
|
||||
helpText="Use Lidarr's built-in updater or a script"
|
||||
helpLink="https://github.com/lidarr/Lidarr/wiki/Updating"
|
||||
onChange={onInputChange}
|
||||
{...updateMechanism}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
updateMechanism.value === 'script' &&
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>Script Path</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="updateScriptPath"
|
||||
helpText="Path to a custom script that takes an extracted update package and handle the remainder of the update process"
|
||||
onChange={onInputChange}
|
||||
{...updateScriptPath}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</FieldSet>
|
||||
}
|
||||
</Form>
|
||||
}
|
||||
</PageContentBodyConnector>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isConfirmApiKeyResetModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title="Reset API Key"
|
||||
message="Are you sure you want to reset your API Key?"
|
||||
confirmLabel="Reset"
|
||||
onConfirm={this.onConfirmResetApiKey}
|
||||
onCancel={this.onCloseResetApiKeyModal}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isRestartRequiredModalOpen}
|
||||
isOpen={this.state.isRestartRequiredModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title="Restart Lidarr"
|
||||
message="Lidarr requires a restart to apply changes, do you want to restart now?"
|
||||
|
|
|
@ -0,0 +1,150 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { inputTypes, sizes } from 'Helpers/Props';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
|
||||
function HostSettings(props) {
|
||||
const {
|
||||
advancedSettings,
|
||||
settings,
|
||||
isWindows,
|
||||
mode,
|
||||
onInputChange
|
||||
} = props;
|
||||
|
||||
const {
|
||||
bindAddress,
|
||||
port,
|
||||
urlBase,
|
||||
enableSsl,
|
||||
sslPort,
|
||||
sslCertHash,
|
||||
launchBrowser
|
||||
} = settings;
|
||||
|
||||
return (
|
||||
<FieldSet legend="Host">
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>Bind Address</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="bindAddress"
|
||||
helpText="Valid IP4 address or '*' for all interfaces"
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...bindAddress}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Port Number</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="port"
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...port}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>URL Base</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="urlBase"
|
||||
helpText="For reverse proxy support, default is empty"
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...urlBase}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
size={sizes.MEDIUM}
|
||||
>
|
||||
<FormLabel>Enable SSL</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="enableSsl"
|
||||
helpText=" Requires restart running as administrator to take effect"
|
||||
onChange={onInputChange}
|
||||
{...enableSsl}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
enableSsl.value &&
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>SSL Port</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="sslPort"
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...sslPort}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
|
||||
{
|
||||
isWindows && enableSsl.value &&
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>SSL Cert Hash</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="sslCertHash"
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...sslCertHash}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
|
||||
{
|
||||
mode !== 'service' &&
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>Open browser on start</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="launchBrowser"
|
||||
helpText=" Open a web browser and navigate to Lidarr homepage on app start."
|
||||
onChange={onInputChange}
|
||||
{...launchBrowser}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
HostSettings.propTypes = {
|
||||
advancedSettings: PropTypes.bool.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
isWindows: PropTypes.bool.isRequired,
|
||||
mode: PropTypes.string.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default HostSettings;
|
|
@ -0,0 +1,48 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
|
||||
function LoggingSettings(props) {
|
||||
const {
|
||||
settings,
|
||||
onInputChange
|
||||
} = props;
|
||||
|
||||
const {
|
||||
logLevel
|
||||
} = settings;
|
||||
|
||||
const logLevelOptions = [
|
||||
{ key: 'info', value: 'Info' },
|
||||
{ key: 'debug', value: 'Debug' },
|
||||
{ key: 'trace', value: 'Trace' }
|
||||
];
|
||||
|
||||
return (
|
||||
<FieldSet legend="Logging">
|
||||
<FormGroup>
|
||||
<FormLabel>Log Level</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="logLevel"
|
||||
values={logLevelOptions}
|
||||
helpTextWarning={logLevel.value === 'trace' ? 'Trace logging should only be enabled temporarily' : undefined}
|
||||
onChange={onInputChange}
|
||||
{...logLevel}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
LoggingSettings.propTypes = {
|
||||
settings: PropTypes.object.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default LoggingSettings;
|
|
@ -0,0 +1,139 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { inputTypes, sizes } from 'Helpers/Props';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
|
||||
function ProxySettings(props) {
|
||||
const {
|
||||
settings,
|
||||
onInputChange
|
||||
} = props;
|
||||
|
||||
const {
|
||||
proxyEnabled,
|
||||
proxyType,
|
||||
proxyHostname,
|
||||
proxyPort,
|
||||
proxyUsername,
|
||||
proxyPassword,
|
||||
proxyBypassFilter,
|
||||
proxyBypassLocalAddresses
|
||||
} = settings;
|
||||
|
||||
const proxyTypeOptions = [
|
||||
{ key: 'http', value: 'HTTP(S)' },
|
||||
{ key: 'socks4', value: 'Socks4' },
|
||||
{ key: 'socks5', value: 'Socks5 (Support TOR)' }
|
||||
];
|
||||
|
||||
return (
|
||||
<FieldSet legend="Proxy">
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>Use Proxy</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="proxyEnabled"
|
||||
onChange={onInputChange}
|
||||
{...proxyEnabled}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
proxyEnabled.value &&
|
||||
<div>
|
||||
<FormGroup>
|
||||
<FormLabel>Proxy Type</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="proxyType"
|
||||
values={proxyTypeOptions}
|
||||
onChange={onInputChange}
|
||||
{...proxyType}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Hostname</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="proxyHostname"
|
||||
onChange={onInputChange}
|
||||
{...proxyHostname}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Port</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="proxyPort"
|
||||
onChange={onInputChange}
|
||||
{...proxyPort}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Username</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="proxyUsername"
|
||||
helpText="You only need to enter a username and password if one is required. Leave them blank otherwise."
|
||||
onChange={onInputChange}
|
||||
{...proxyUsername}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Password</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PASSWORD}
|
||||
name="proxyPassword"
|
||||
helpText="You only need to enter a username and password if one is required. Leave them blank otherwise."
|
||||
onChange={onInputChange}
|
||||
{...proxyPassword}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Ignored Addresses</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="proxyBypassFilter"
|
||||
helpText="Use ',' as a separator, and '*.' as a wildcard for subdomains"
|
||||
onChange={onInputChange}
|
||||
{...proxyBypassFilter}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>Bypass Proxy for Local Addresses</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="proxyBypassLocalAddresses"
|
||||
onChange={onInputChange}
|
||||
{...proxyBypassLocalAddresses}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
}
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
ProxySettings.propTypes = {
|
||||
settings: PropTypes.object.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ProxySettings;
|
|
@ -0,0 +1,170 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { icons, kinds, inputTypes } from 'Helpers/Props';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Icon from 'Components/Icon';
|
||||
import ClipboardButton from 'Components/Link/ClipboardButton';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
|
||||
class SecuritySettings extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isConfirmApiKeyResetModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onApikeyFocus = (event) => {
|
||||
event.target.select();
|
||||
}
|
||||
|
||||
onResetApiKeyPress = () => {
|
||||
this.setState({ isConfirmApiKeyResetModalOpen: true });
|
||||
}
|
||||
|
||||
onConfirmResetApiKey = () => {
|
||||
this.setState({ isConfirmApiKeyResetModalOpen: false });
|
||||
this.props.onConfirmResetApiKey();
|
||||
}
|
||||
|
||||
onCloseResetApiKeyModal = () => {
|
||||
this.setState({ isConfirmApiKeyResetModalOpen: false });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
settings,
|
||||
isResettingApiKey,
|
||||
onInputChange
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
authenticationMethod,
|
||||
username,
|
||||
password,
|
||||
apiKey
|
||||
} = settings;
|
||||
|
||||
const authenticationMethodOptions = [
|
||||
{ key: 'none', value: 'None' },
|
||||
{ key: 'basic', value: 'Basic (Browser Popup)' },
|
||||
{ key: 'forms', value: 'Forms (Login Page)' }
|
||||
];
|
||||
|
||||
const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none';
|
||||
|
||||
return (
|
||||
<FieldSet legend="Security">
|
||||
<FormGroup>
|
||||
<FormLabel>Authentication</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="authenticationMethod"
|
||||
values={authenticationMethodOptions}
|
||||
helpText="Require Username and Password to access Lidarr"
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...authenticationMethod}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
authenticationEnabled &&
|
||||
<FormGroup>
|
||||
<FormLabel>Username</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="username"
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...username}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
|
||||
{
|
||||
authenticationEnabled &&
|
||||
<FormGroup>
|
||||
<FormLabel>Password</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PASSWORD}
|
||||
name="password"
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
onChange={onInputChange}
|
||||
{...password}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>API Key</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="apiKey"
|
||||
readOnly={true}
|
||||
helpTextWarning="Requires restart to take effect"
|
||||
buttons={[
|
||||
<ClipboardButton
|
||||
key="copy"
|
||||
value={apiKey.value}
|
||||
kind={kinds.DEFAULT}
|
||||
/>,
|
||||
|
||||
<FormInputButton
|
||||
key="reset"
|
||||
kind={kinds.DANGER}
|
||||
onPress={this.onResetApiKeyPress}
|
||||
>
|
||||
<Icon
|
||||
name={icons.REFRESH}
|
||||
isSpinning={isResettingApiKey}
|
||||
/>
|
||||
</FormInputButton>
|
||||
]}
|
||||
onChange={onInputChange}
|
||||
onFocus={this.onApikeyFocus}
|
||||
{...apiKey}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={this.state.isConfirmApiKeyResetModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title="Reset API Key"
|
||||
message="Are you sure you want to reset your API Key?"
|
||||
confirmLabel="Reset"
|
||||
onConfirm={this.onConfirmResetApiKey}
|
||||
onCancel={this.onCloseResetApiKeyModal}
|
||||
/>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SecuritySettings.propTypes = {
|
||||
settings: PropTypes.object.isRequired,
|
||||
isResettingApiKey: PropTypes.bool.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onConfirmResetApiKey: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default SecuritySettings;
|
|
@ -0,0 +1,117 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { inputTypes, sizes } from 'Helpers/Props';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
|
||||
function UpdateSettings(props) {
|
||||
const {
|
||||
advancedSettings,
|
||||
settings,
|
||||
isMono,
|
||||
onInputChange
|
||||
} = props;
|
||||
|
||||
const {
|
||||
branch,
|
||||
updateAutomatically,
|
||||
updateMechanism,
|
||||
updateScriptPath
|
||||
} = settings;
|
||||
|
||||
if (!advancedSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updateOptions = [
|
||||
{ key: 'builtIn', value: 'Built-In' },
|
||||
{ key: 'script', value: 'Script' }
|
||||
];
|
||||
|
||||
return (
|
||||
<FieldSet legend="Updates">
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>Branch</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="branch"
|
||||
helpText="Branch to use to update Lidarr"
|
||||
helpLink="https://github.com/Lidarr/Lidarr/wiki/Release-Branches"
|
||||
onChange={onInputChange}
|
||||
{...branch}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
isMono &&
|
||||
<div>
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
size={sizes.MEDIUM}
|
||||
>
|
||||
<FormLabel>Automatic</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="updateAutomatically"
|
||||
helpText="Automatically download and install updates. You will still be able to install from System: Updates"
|
||||
onChange={onInputChange}
|
||||
{...updateAutomatically}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>Mechanism</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="updateMechanism"
|
||||
values={updateOptions}
|
||||
helpText="Use Lidarr's built-in updater or a script"
|
||||
helpLink="https://github.com/Lidarr/Lidarr/wiki/Updating"
|
||||
onChange={onInputChange}
|
||||
{...updateMechanism}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
updateMechanism.value === 'script' &&
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>Script Path</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="updateScriptPath"
|
||||
helpText="Path to a custom script that takes an extracted update package and handle the remainder of the update process"
|
||||
onChange={onInputChange}
|
||||
{...updateScriptPath}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
UpdateSettings.propTypes = {
|
||||
advancedSettings: PropTypes.bool.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
isMono: PropTypes.bool.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default UpdateSettings;
|
|
@ -96,43 +96,19 @@ class Naming extends Component {
|
|||
if (examples.singleTrackExample) {
|
||||
standardTrackFormatHelpTexts.push(`Single Track: ${examples.singleTrackExample}`);
|
||||
} else {
|
||||
standardTrackFormatErrors.push('Single Track: Invalid Format');
|
||||
standardTrackFormatErrors.push({ message: 'Single Track: Invalid Format' });
|
||||
}
|
||||
|
||||
// if (examples.multiEpisodeExample) {
|
||||
// standardTrackFormatHelpTexts.push(`Multi Episode: ${examples.multiEpisodeExample}`);
|
||||
// } else {
|
||||
// standardTrackFormatErrors.push('Multi Episode: Invalid Format');
|
||||
// }
|
||||
|
||||
// if (examples.dailyEpisodeExample) {
|
||||
// dailyEpisodeFormatHelpTexts.push(`Example: ${examples.dailyEpisodeExample}`);
|
||||
// } else {
|
||||
// dailyEpisodeFormatErrors.push('Invalid Format');
|
||||
// }
|
||||
|
||||
// if (examples.animeEpisodeExample) {
|
||||
// animeEpisodeFormatHelpTexts.push(`Single Episode: ${examples.animeEpisodeExample}`);
|
||||
// } else {
|
||||
// animeEpisodeFormatErrors.push('Single Episode: Invalid Format');
|
||||
// }
|
||||
|
||||
// if (examples.animeMultiEpisodeExample) {
|
||||
// animeEpisodeFormatHelpTexts.push(`Multi Episode: ${examples.animeMultiEpisodeExample}`);
|
||||
// } else {
|
||||
// animeEpisodeFormatErrors.push('Multi Episode: Invalid Format');
|
||||
// }
|
||||
|
||||
if (examples.artistFolderExample) {
|
||||
artistFolderFormatHelpTexts.push(`Example: ${examples.artistFolderExample}`);
|
||||
} else {
|
||||
artistFolderFormatErrors.push('Invalid Format');
|
||||
artistFolderFormatErrors.push({ message: 'Invalid Format' });
|
||||
}
|
||||
|
||||
if (examples.albumFolderExample) {
|
||||
albumFolderFormatHelpTexts.push(`Example: ${examples.albumFolderExample}`);
|
||||
} else {
|
||||
albumFolderFormatErrors.push('Invalid Format');
|
||||
albumFolderFormatErrors.push({ message: 'Invalid Format' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,9 @@ class NamingModal extends Component {
|
|||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._selectionStart = null;
|
||||
this._selectionEnd = null;
|
||||
|
||||
this.state = {
|
||||
case: 'title'
|
||||
};
|
||||
|
@ -33,6 +36,40 @@ class NamingModal extends Component {
|
|||
this.setState({ case: event.value });
|
||||
}
|
||||
|
||||
onInputSelectionChange = (selectionStart, selectionEnd) => {
|
||||
this._selectionStart = selectionStart;
|
||||
this._selectionEnd = selectionEnd;
|
||||
}
|
||||
|
||||
onOptionPress = ({ isFullFilename, tokenValue }) => {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
onInputChange
|
||||
} = this.props;
|
||||
|
||||
const selectionStart = this._selectionStart;
|
||||
const selectionEnd = this._selectionEnd;
|
||||
|
||||
if (isFullFilename) {
|
||||
onInputChange({ name, value: tokenValue });
|
||||
} else if (selectionStart == null) {
|
||||
onInputChange({
|
||||
name,
|
||||
value: `${value}${tokenValue}`
|
||||
});
|
||||
} else {
|
||||
const start = value.substring(0, selectionStart);
|
||||
const end = value.substring(selectionEnd);
|
||||
const newValue = `${start}${tokenValue}${end}`;
|
||||
|
||||
onInputChange({ name, value: newValue });
|
||||
this._selectionStart = newValue.length - 1;
|
||||
this._selectionEnd = newValue.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
|
@ -188,7 +225,7 @@ class NamingModal extends Component {
|
|||
isFullFilename={true}
|
||||
tokenCase={this.state.case}
|
||||
size={sizes.LARGE}
|
||||
onInputChange={onInputChange}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -210,7 +247,7 @@ class NamingModal extends Component {
|
|||
token={token}
|
||||
example={example}
|
||||
tokenCase={this.state.case}
|
||||
onInputChange={onInputChange}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -234,7 +271,7 @@ class NamingModal extends Component {
|
|||
token={token}
|
||||
example={example}
|
||||
tokenCase={this.state.case}
|
||||
onInputChange={onInputChange}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -255,7 +292,7 @@ class NamingModal extends Component {
|
|||
token={token}
|
||||
example={example}
|
||||
tokenCase={this.state.case}
|
||||
onInputChange={onInputChange}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -281,7 +318,7 @@ class NamingModal extends Component {
|
|||
token={token}
|
||||
example={example}
|
||||
tokenCase={this.state.case}
|
||||
onInputChange={onInputChange}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -302,7 +339,7 @@ class NamingModal extends Component {
|
|||
token={token}
|
||||
example={example}
|
||||
tokenCase={this.state.case}
|
||||
onInputChange={onInputChange}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -323,7 +360,7 @@ class NamingModal extends Component {
|
|||
token={token}
|
||||
example={example}
|
||||
tokenCase={this.state.case}
|
||||
onInputChange={onInputChange}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -350,7 +387,7 @@ class NamingModal extends Component {
|
|||
token={token}
|
||||
example={example}
|
||||
tokenCase={this.state.case}
|
||||
onInputChange={onInputChange}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -371,7 +408,7 @@ class NamingModal extends Component {
|
|||
token={token}
|
||||
example={example}
|
||||
tokenCase={this.state.case}
|
||||
onInputChange={onInputChange}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -392,7 +429,7 @@ class NamingModal extends Component {
|
|||
token={token}
|
||||
example={example}
|
||||
tokenCase={this.state.case}
|
||||
onInputChange={onInputChange}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -413,7 +450,7 @@ class NamingModal extends Component {
|
|||
token={token}
|
||||
example={example}
|
||||
tokenCase={this.state.case}
|
||||
onInputChange={onInputChange}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -435,7 +472,7 @@ class NamingModal extends Component {
|
|||
example={example}
|
||||
tokenCase={this.state.case}
|
||||
size={sizes.LARGE}
|
||||
onInputChange={onInputChange}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -452,6 +489,7 @@ class NamingModal extends Component {
|
|||
name={name}
|
||||
value={value}
|
||||
onChange={onInputChange}
|
||||
onSelectionChange={this.onInputSelectionChange}
|
||||
/>
|
||||
<Button onPress={onModalClose}>
|
||||
Close
|
||||
|
|
|
@ -12,30 +12,21 @@ class NamingOption extends Component {
|
|||
|
||||
onPress = () => {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
token,
|
||||
tokenCase,
|
||||
isFullFilename,
|
||||
onInputChange
|
||||
onPress
|
||||
} = this.props;
|
||||
|
||||
let newValue = token;
|
||||
let tokenValue = token;
|
||||
|
||||
if (tokenCase === 'lower') {
|
||||
newValue = token.toLowerCase();
|
||||
tokenValue = token.toLowerCase();
|
||||
} else if (tokenCase === 'upper') {
|
||||
newValue = token.toUpperCase();
|
||||
tokenValue = token.toUpperCase();
|
||||
}
|
||||
|
||||
if (isFullFilename) {
|
||||
onInputChange({ name, value: newValue });
|
||||
} else {
|
||||
onInputChange({
|
||||
name,
|
||||
value: `${value}${newValue}`
|
||||
});
|
||||
}
|
||||
onPress({ isFullFilename, tokenValue });
|
||||
}
|
||||
|
||||
//
|
||||
|
@ -67,14 +58,12 @@ class NamingOption extends Component {
|
|||
}
|
||||
|
||||
NamingOption.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
token: PropTypes.string.isRequired,
|
||||
example: PropTypes.string.isRequired,
|
||||
tokenCase: PropTypes.string.isRequired,
|
||||
isFullFilename: PropTypes.bool.isRequired,
|
||||
size: PropTypes.oneOf([sizes.SMALL, sizes.LARGE]),
|
||||
onInputChange: PropTypes.func.isRequired
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
NamingOption.defaultProps = {
|
||||
|
|
|
@ -67,7 +67,7 @@ class EditLanguageProfileModalContentConnector extends Component {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.id) {
|
||||
if (!this.props.id && !this.props.isPopulated) {
|
||||
this.props.fetchLanguageProfileSchema();
|
||||
}
|
||||
}
|
||||
|
@ -176,6 +176,7 @@ class EditLanguageProfileModalContentConnector extends Component {
|
|||
EditLanguageProfileModalContentConnector.propTypes = {
|
||||
id: PropTypes.number,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
item: PropTypes.object.isRequired,
|
||||
|
|
|
@ -4,6 +4,11 @@
|
|||
width: 300px;
|
||||
}
|
||||
|
||||
.nameContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.name {
|
||||
@add-mixin truncate;
|
||||
|
||||
|
@ -12,8 +17,15 @@
|
|||
font-size: 24px;
|
||||
}
|
||||
|
||||
.cloneButton {
|
||||
composes: button from 'Components/Link/IconButton.css';
|
||||
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.languages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 5px;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import EditLanguageProfileModalConnector from './EditLanguageProfileModalConnector';
|
||||
import styles from './LanguageProfile.css';
|
||||
|
@ -47,6 +48,15 @@ class LanguageProfile extends Component {
|
|||
this.props.onConfirmDeleteLanguageProfile(this.props.id);
|
||||
}
|
||||
|
||||
onCloneLanguageProfilePress = () => {
|
||||
const {
|
||||
id,
|
||||
onCloneLanguageProfilePress
|
||||
} = this.props;
|
||||
|
||||
onCloneLanguageProfilePress(id);
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
|
@ -62,10 +72,20 @@ class LanguageProfile extends Component {
|
|||
return (
|
||||
<Card
|
||||
className={styles.languageProfile}
|
||||
overlayContent={true}
|
||||
onPress={this.onEditLanguageProfilePress}
|
||||
>
|
||||
<div className={styles.name}>
|
||||
{name}
|
||||
<div className={styles.nameContainer}>
|
||||
<div className={styles.name}>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
className={styles.cloneButton}
|
||||
title="Clone Profile"
|
||||
name={icons.CLONE}
|
||||
onPress={this.onCloneLanguageProfilePress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.languages}>
|
||||
|
@ -118,7 +138,8 @@ LanguageProfile.propTypes = {
|
|||
cutoff: PropTypes.object.isRequired,
|
||||
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isDeleting: PropTypes.bool.isRequired,
|
||||
onConfirmDeleteLanguageProfile: PropTypes.func.isRequired
|
||||
onConfirmDeleteLanguageProfile: PropTypes.func.isRequired,
|
||||
onCloneLanguageProfilePress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default LanguageProfile;
|
||||
|
|
|
@ -26,6 +26,11 @@ class LanguageProfiles extends Component {
|
|||
//
|
||||
// Listeners
|
||||
|
||||
onCloneLanguageProfilePress = (id) => {
|
||||
this.props.onCloneLanguageProfilePress(id);
|
||||
this.setState({ isLanguageProfileModalOpen: true });
|
||||
}
|
||||
|
||||
onEditLanguageProfilePress = () => {
|
||||
this.setState({ isLanguageProfileModalOpen: true });
|
||||
}
|
||||
|
@ -62,6 +67,7 @@ class LanguageProfiles extends Component {
|
|||
{...item}
|
||||
isDeleting={isDeleting}
|
||||
onConfirmDeleteLanguageProfile={onConfirmDeleteLanguageProfile}
|
||||
onCloneLanguageProfilePress={this.onCloneLanguageProfilePress}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
@ -96,7 +102,8 @@ LanguageProfiles.propTypes = {
|
|||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isDeleting: PropTypes.bool.isRequired,
|
||||
onConfirmDeleteLanguageProfile: PropTypes.func.isRequired
|
||||
onConfirmDeleteLanguageProfile: PropTypes.func.isRequired,
|
||||
onCloneLanguageProfilePress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default LanguageProfiles;
|
||||
|
|
|
@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
|
|||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchLanguageProfiles, deleteLanguageProfile } from 'Store/Actions/settingsActions';
|
||||
import { fetchLanguageProfiles, deleteLanguageProfile, cloneLanguageProfile } from 'Store/Actions/settingsActions';
|
||||
import LanguageProfiles from './LanguageProfiles';
|
||||
|
||||
function createMapStateToProps() {
|
||||
|
@ -19,8 +19,9 @@ function createMapStateToProps() {
|
|||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchLanguageProfiles,
|
||||
deleteLanguageProfile
|
||||
dispatchFetchLanguageProfiles: fetchLanguageProfiles,
|
||||
dispatchDeleteLanguageProfile: deleteLanguageProfile,
|
||||
dispatchCloneLanguageProfile: cloneLanguageProfile
|
||||
};
|
||||
|
||||
class LanguageProfilesConnector extends Component {
|
||||
|
@ -29,14 +30,18 @@ class LanguageProfilesConnector extends Component {
|
|||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchLanguageProfiles();
|
||||
this.props.dispatchFetchLanguageProfiles();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onConfirmDeleteLanguageProfile = (id) => {
|
||||
this.props.deleteLanguageProfile({ id });
|
||||
this.props.dispatchDeleteLanguageProfile({ id });
|
||||
}
|
||||
|
||||
onCloneLanguageProfilePress = (id) => {
|
||||
this.props.dispatchCloneLanguageProfile({ id });
|
||||
}
|
||||
|
||||
//
|
||||
|
@ -46,6 +51,7 @@ class LanguageProfilesConnector extends Component {
|
|||
return (
|
||||
<LanguageProfiles
|
||||
onConfirmDeleteLanguageProfile={this.onConfirmDeleteLanguageProfile}
|
||||
onCloneLanguageProfilePress={this.onCloneLanguageProfilePress}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
|
@ -53,8 +59,9 @@ class LanguageProfilesConnector extends Component {
|
|||
}
|
||||
|
||||
LanguageProfilesConnector.propTypes = {
|
||||
fetchLanguageProfiles: PropTypes.func.isRequired,
|
||||
deleteLanguageProfile: PropTypes.func.isRequired
|
||||
dispatchFetchLanguageProfiles: PropTypes.func.isRequired,
|
||||
dispatchDeleteLanguageProfile: PropTypes.func.isRequired,
|
||||
dispatchCloneLanguageProfile: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(LanguageProfilesConnector);
|
||||
|
|
|
@ -92,7 +92,7 @@ class EditMetadataProfileModalContentConnector extends Component {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.id) {
|
||||
if (!this.props.id && !this.props.isPopulated) {
|
||||
this.props.fetchMetadataProfileSchema();
|
||||
}
|
||||
}
|
||||
|
@ -162,6 +162,7 @@ class EditMetadataProfileModalContentConnector extends Component {
|
|||
EditMetadataProfileModalContentConnector.propTypes = {
|
||||
id: PropTypes.number,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
item: PropTypes.object.isRequired,
|
||||
|
|
|
@ -4,6 +4,11 @@
|
|||
width: 300px;
|
||||
}
|
||||
|
||||
.nameContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.name {
|
||||
@add-mixin truncate;
|
||||
|
||||
|
@ -12,8 +17,15 @@
|
|||
font-size: 24px;
|
||||
}
|
||||
|
||||
.cloneButton {
|
||||
composes: button from 'Components/Link/IconButton.css';
|
||||
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.albumTypes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 5px;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import EditMetadataProfileModalConnector from './EditMetadataProfileModalConnector';
|
||||
import styles from './MetadataProfile.css';
|
||||
|
@ -47,6 +48,17 @@ class MetadataProfile extends Component {
|
|||
this.props.onConfirmDeleteMetadataProfile(this.props.id);
|
||||
}
|
||||
|
||||
onCloneMetadataProfilePress = () => {
|
||||
const {
|
||||
id,
|
||||
onCloneMetadataProfilePress
|
||||
} = this.props;
|
||||
|
||||
onCloneMetadataProfilePress(id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
|
@ -62,10 +74,20 @@ class MetadataProfile extends Component {
|
|||
return (
|
||||
<Card
|
||||
className={styles.metadataProfile}
|
||||
overlayContent={true}
|
||||
onPress={this.onEditMetadataProfilePress}
|
||||
>
|
||||
<div className={styles.name}>
|
||||
{name}
|
||||
<div className={styles.nameContainer}>
|
||||
<div className={styles.name}>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
className={styles.cloneButton}
|
||||
title="Clone Profile"
|
||||
name={icons.CLONE}
|
||||
onPress={this.onCloneMetadataProfilePress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.albumTypes}>
|
||||
|
@ -136,7 +158,9 @@ MetadataProfile.propTypes = {
|
|||
primaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
secondaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isDeleting: PropTypes.bool.isRequired,
|
||||
onConfirmDeleteMetadataProfile: PropTypes.func.isRequired
|
||||
onConfirmDeleteMetadataProfile: PropTypes.func.isRequired,
|
||||
onCloneMetadataProfilePress: PropTypes.func.isRequired
|
||||
|
||||
};
|
||||
|
||||
export default MetadataProfile;
|
||||
|
|
|
@ -26,6 +26,11 @@ class MetadataProfiles extends Component {
|
|||
//
|
||||
// Listeners
|
||||
|
||||
onCloneMetadataProfilePress = (id) => {
|
||||
this.props.onCloneMetadataProfilePress(id);
|
||||
this.setState({ isMetadataProfileModalOpen: true });
|
||||
}
|
||||
|
||||
onEditMetadataProfilePress = () => {
|
||||
this.setState({ isMetadataProfileModalOpen: true });
|
||||
}
|
||||
|
@ -62,6 +67,7 @@ class MetadataProfiles extends Component {
|
|||
{...item}
|
||||
isDeleting={isDeleting}
|
||||
onConfirmDeleteMetadataProfile={onConfirmDeleteMetadataProfile}
|
||||
onCloneMetadataProfilePress={this.onCloneMetadataProfilePress}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
@ -96,7 +102,8 @@ MetadataProfiles.propTypes = {
|
|||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isDeleting: PropTypes.bool.isRequired,
|
||||
onConfirmDeleteMetadataProfile: PropTypes.func.isRequired
|
||||
onConfirmDeleteMetadataProfile: PropTypes.func.isRequired,
|
||||
onCloneMetadataProfilePress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default MetadataProfiles;
|
||||
|
|
|
@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
|
|||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchMetadataProfiles, deleteMetadataProfile } from 'Store/Actions/settingsActions';
|
||||
import { fetchMetadataProfiles, deleteMetadataProfile, cloneMetadataProfile } from 'Store/Actions/settingsActions';
|
||||
import MetadataProfiles from './MetadataProfiles';
|
||||
|
||||
function createMapStateToProps() {
|
||||
|
@ -19,8 +19,9 @@ function createMapStateToProps() {
|
|||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchMetadataProfiles,
|
||||
deleteMetadataProfile
|
||||
dispatchFetchMetadataProfiles: fetchMetadataProfiles,
|
||||
dispatchDeleteMetadataProfile: deleteMetadataProfile,
|
||||
dispatchCloneMetadataProfile: cloneMetadataProfile
|
||||
};
|
||||
|
||||
class MetadataProfilesConnector extends Component {
|
||||
|
@ -29,14 +30,18 @@ class MetadataProfilesConnector extends Component {
|
|||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchMetadataProfiles();
|
||||
this.props.dispatchFetchMetadataProfiles();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onConfirmDeleteMetadataProfile = (id) => {
|
||||
this.props.deleteMetadataProfile({ id });
|
||||
this.props.dispatchDeleteMetadataProfile({ id });
|
||||
}
|
||||
|
||||
onCloneMetadataProfilePress = (id) => {
|
||||
this.props.dispatchCloneMetadataProfile({ id });
|
||||
}
|
||||
|
||||
//
|
||||
|
@ -46,6 +51,7 @@ class MetadataProfilesConnector extends Component {
|
|||
return (
|
||||
<MetadataProfiles
|
||||
onConfirmDeleteMetadataProfile={this.onConfirmDeleteMetadataProfile}
|
||||
onCloneMetadataProfilePress={this.onCloneMetadataProfilePress}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
|
@ -53,8 +59,9 @@ class MetadataProfilesConnector extends Component {
|
|||
}
|
||||
|
||||
MetadataProfilesConnector.propTypes = {
|
||||
fetchMetadataProfiles: PropTypes.func.isRequired,
|
||||
deleteMetadataProfile: PropTypes.func.isRequired
|
||||
dispatchFetchMetadataProfiles: PropTypes.func.isRequired,
|
||||
dispatchDeleteMetadataProfile: PropTypes.func.isRequired,
|
||||
dispatchCloneMetadataProfile: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(MetadataProfilesConnector);
|
||||
|
|
|
@ -94,12 +94,12 @@ class EditQualityProfileModalContentConnector extends Component {
|
|||
dragQualityIndex: null,
|
||||
dropQualityIndex: null,
|
||||
dropPosition: null,
|
||||
editGroups: true
|
||||
editGroups: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.id) {
|
||||
if (!this.props.id && !this.props.isPopulated) {
|
||||
this.props.fetchQualityProfileSchema();
|
||||
}
|
||||
}
|
||||
|
@ -429,6 +429,7 @@ class EditQualityProfileModalContentConnector extends Component {
|
|||
EditQualityProfileModalContentConnector.propTypes = {
|
||||
id: PropTypes.number,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
item: PropTypes.object.isRequired,
|
||||
|
|
|
@ -4,6 +4,11 @@
|
|||
width: 300px;
|
||||
}
|
||||
|
||||
.nameContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.name {
|
||||
@add-mixin truncate;
|
||||
|
||||
|
@ -12,10 +17,17 @@
|
|||
font-size: 24px;
|
||||
}
|
||||
|
||||
.cloneButton {
|
||||
composes: button from 'Components/Link/IconButton.css';
|
||||
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.qualities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 5px;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.tooltipLabel {
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
|
||||
|
@ -48,6 +49,17 @@ class QualityProfile extends Component {
|
|||
this.props.onConfirmDeleteQualityProfile(this.props.id);
|
||||
}
|
||||
|
||||
onCloneQualityProfilePress = () => {
|
||||
const {
|
||||
id,
|
||||
onCloneQualityProfilePress
|
||||
} = this.props;
|
||||
|
||||
onCloneQualityProfilePress(id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
|
@ -63,10 +75,20 @@ class QualityProfile extends Component {
|
|||
return (
|
||||
<Card
|
||||
className={styles.qualityProfile}
|
||||
overlayContent={true}
|
||||
onPress={this.onEditQualityProfilePress}
|
||||
>
|
||||
<div className={styles.name}>
|
||||
{name}
|
||||
<div className={styles.nameContainer}>
|
||||
<div className={styles.name}>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
className={styles.cloneButton}
|
||||
title="Clone Profile"
|
||||
name={icons.CLONE}
|
||||
onPress={this.onCloneQualityProfilePress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.qualities}>
|
||||
|
@ -157,7 +179,8 @@ QualityProfile.propTypes = {
|
|||
cutoff: PropTypes.number.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isDeleting: PropTypes.bool.isRequired,
|
||||
onConfirmDeleteQualityProfile: PropTypes.func.isRequired
|
||||
onConfirmDeleteQualityProfile: PropTypes.func.isRequired,
|
||||
onCloneQualityProfilePress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default QualityProfile;
|
||||
|
|
|
@ -25,10 +25,6 @@ class QualityProfileItems extends Component {
|
|||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.onToggleEditGroupsMode();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
|
|
|
@ -26,6 +26,11 @@ class QualityProfiles extends Component {
|
|||
//
|
||||
// Listeners
|
||||
|
||||
onCloneQualityProfilePress = (id) => {
|
||||
this.props.onCloneQualityProfilePress(id);
|
||||
this.setState({ isQualityProfileModalOpen: true });
|
||||
}
|
||||
|
||||
onEditQualityProfilePress = () => {
|
||||
this.setState({ isQualityProfileModalOpen: true });
|
||||
}
|
||||
|
@ -51,7 +56,7 @@ class QualityProfiles extends Component {
|
|||
>
|
||||
<PageSectionContent
|
||||
errorMessage="Unable to load Quality Profiles"
|
||||
{...otherProps}
|
||||
{...otherProps}c={true}
|
||||
>
|
||||
<div className={styles.qualityProfiles}>
|
||||
{
|
||||
|
@ -62,6 +67,7 @@ class QualityProfiles extends Component {
|
|||
{...item}
|
||||
isDeleting={isDeleting}
|
||||
onConfirmDeleteQualityProfile={onConfirmDeleteQualityProfile}
|
||||
onCloneQualityProfilePress={this.onCloneQualityProfilePress}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
@ -95,7 +101,8 @@ QualityProfiles.propTypes = {
|
|||
error: PropTypes.object,
|
||||
isDeleting: PropTypes.bool.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onConfirmDeleteQualityProfile: PropTypes.func.isRequired
|
||||
onConfirmDeleteQualityProfile: PropTypes.func.isRequired,
|
||||
onCloneQualityProfilePress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default QualityProfiles;
|
||||
|
|
|
@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
|
|||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchQualityProfiles, deleteQualityProfile } from 'Store/Actions/settingsActions';
|
||||
import { fetchQualityProfiles, deleteQualityProfile, cloneQualityProfile } from 'Store/Actions/settingsActions';
|
||||
import QualityProfiles from './QualityProfiles';
|
||||
|
||||
function createMapStateToProps() {
|
||||
|
@ -17,8 +17,9 @@ function createMapStateToProps() {
|
|||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchQualityProfiles,
|
||||
deleteQualityProfile
|
||||
dispatchFetchQualityProfiles: fetchQualityProfiles,
|
||||
dispatchDeleteQualityProfile: deleteQualityProfile,
|
||||
dispatchCloneQualityProfile: cloneQualityProfile
|
||||
};
|
||||
|
||||
class QualityProfilesConnector extends Component {
|
||||
|
@ -27,14 +28,18 @@ class QualityProfilesConnector extends Component {
|
|||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchQualityProfiles();
|
||||
this.props.dispatchFetchQualityProfiles();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onConfirmDeleteQualityProfile = (id) => {
|
||||
this.props.deleteQualityProfile({ id });
|
||||
this.props.dispatchDeleteQualityProfile({ id });
|
||||
}
|
||||
|
||||
onCloneQualityProfilePress = (id) => {
|
||||
this.props.dispatchCloneQualityProfile({ id });
|
||||
}
|
||||
|
||||
//
|
||||
|
@ -44,6 +49,7 @@ class QualityProfilesConnector extends Component {
|
|||
return (
|
||||
<QualityProfiles
|
||||
onConfirmDeleteQualityProfile={this.onConfirmDeleteQualityProfile}
|
||||
onCloneQualityProfilePress={this.onCloneQualityProfilePress}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
|
@ -51,8 +57,9 @@ class QualityProfilesConnector extends Component {
|
|||
}
|
||||
|
||||
QualityProfilesConnector.propTypes = {
|
||||
fetchQualityProfiles: PropTypes.func.isRequired,
|
||||
deleteQualityProfile: PropTypes.func.isRequired
|
||||
dispatchFetchQualityProfiles: PropTypes.func.isRequired,
|
||||
dispatchDeleteQualityProfile: PropTypes.func.isRequired,
|
||||
dispatchCloneQualityProfile: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(QualityProfilesConnector);
|
||||
|
|
|
@ -13,7 +13,7 @@ function createFetchSchemaHandler(section, url) {
|
|||
dispatch(set({
|
||||
section,
|
||||
isFetchingSchema: false,
|
||||
schemaPopulated: true,
|
||||
isSchemaPopulated: true,
|
||||
schemaError: null,
|
||||
schema: data
|
||||
}));
|
||||
|
@ -23,7 +23,7 @@ function createFetchSchemaHandler(section, url) {
|
|||
dispatch(set({
|
||||
section,
|
||||
isFetchingSchema: false,
|
||||
schemaPopulated: true,
|
||||
isSchemaPopulated: true,
|
||||
schemaError: xhr
|
||||
}));
|
||||
});
|
||||
|
|
|
@ -68,7 +68,7 @@ export default {
|
|||
isPopulated: false,
|
||||
error: null,
|
||||
isFetchingSchema: false,
|
||||
schemaPopulated: false,
|
||||
isSchemaPopulated: false,
|
||||
schemaError: null,
|
||||
schema: [],
|
||||
selectedSchema: {},
|
||||
|
|
|
@ -68,7 +68,7 @@ export default {
|
|||
isPopulated: false,
|
||||
error: null,
|
||||
isFetchingSchema: false,
|
||||
schemaPopulated: false,
|
||||
isSchemaPopulated: false,
|
||||
schemaError: null,
|
||||
schema: [],
|
||||
selectedSchema: {},
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { createAction } from 'redux-actions';
|
||||
import { createThunk } from 'Store/thunks';
|
||||
import getSectionState from 'Utilities/State/getSectionState';
|
||||
import updateSectionState from 'Utilities/State/updateSectionState';
|
||||
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
|
||||
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
|
||||
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
|
||||
|
@ -19,6 +21,7 @@ export const FETCH_LANGUAGE_PROFILE_SCHEMA = 'settings/languageProfiles/fetchLan
|
|||
export const SAVE_LANGUAGE_PROFILE = 'settings/languageProfiles/saveLanguageProfile';
|
||||
export const DELETE_LANGUAGE_PROFILE = 'settings/languageProfiles/deleteLanguageProfile';
|
||||
export const SET_LANGUAGE_PROFILE_VALUE = 'settings/languageProfiles/setLanguageProfileValue';
|
||||
export const CLONE_LANGUAGE_PROFILE = 'settings/languageProfiles/cloneLanguageProfile';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
@ -35,6 +38,8 @@ export const setLanguageProfileValue = createAction(SET_LANGUAGE_PROFILE_VALUE,
|
|||
};
|
||||
});
|
||||
|
||||
export const cloneLanguageProfile = createAction(CLONE_LANGUAGE_PROFILE);
|
||||
|
||||
//
|
||||
// Details
|
||||
|
||||
|
@ -50,7 +55,7 @@ export default {
|
|||
isDeleting: false,
|
||||
deleteError: null,
|
||||
isFetchingSchema: false,
|
||||
schemaPopulated: false,
|
||||
isSchemaPopulated: false,
|
||||
schemaError: null,
|
||||
schema: {},
|
||||
isSaving: false,
|
||||
|
@ -73,7 +78,20 @@ export default {
|
|||
// Reducers
|
||||
|
||||
reducers: {
|
||||
[SET_LANGUAGE_PROFILE_VALUE]: createSetSettingValueReducer(section)
|
||||
[SET_LANGUAGE_PROFILE_VALUE]: createSetSettingValueReducer(section),
|
||||
|
||||
[CLONE_LANGUAGE_PROFILE]: function(state, { payload }) {
|
||||
const id = payload.id;
|
||||
const newState = getSectionState(state, section);
|
||||
const item = newState.items.find((i) => i.id === id);
|
||||
const pendingChanges = { ...item, id: 0 };
|
||||
delete pendingChanges.id;
|
||||
|
||||
pendingChanges.name = `${pendingChanges.name} - Copy`;
|
||||
newState.pendingChanges = pendingChanges;
|
||||
|
||||
return updateSectionState(state, section, newState);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { createAction } from 'redux-actions';
|
||||
import { createThunk } from 'Store/thunks';
|
||||
import getSectionState from 'Utilities/State/getSectionState';
|
||||
import updateSectionState from 'Utilities/State/updateSectionState';
|
||||
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
|
||||
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
|
||||
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
|
||||
|
@ -19,6 +21,7 @@ export const FETCH_METADATA_PROFILE_SCHEMA = 'settings/metadataProfiles/fetchMet
|
|||
export const SAVE_METADATA_PROFILE = 'settings/metadataProfiles/saveMetadataProfile';
|
||||
export const DELETE_METADATA_PROFILE = 'settings/metadataProfiles/deleteMetadataProfile';
|
||||
export const SET_METADATA_PROFILE_VALUE = 'settings/metadataProfiles/setMetadataProfileValue';
|
||||
export const CLONE_METADATA_PROFILE = 'settings/metadataProfiles/cloneMetadataProfile';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
@ -35,6 +38,8 @@ export const setMetadataProfileValue = createAction(SET_METADATA_PROFILE_VALUE,
|
|||
};
|
||||
});
|
||||
|
||||
export const cloneMetadataProfile = createAction(CLONE_METADATA_PROFILE);
|
||||
|
||||
//
|
||||
// Details
|
||||
|
||||
|
@ -50,7 +55,7 @@ export default {
|
|||
isDeleting: false,
|
||||
deleteError: null,
|
||||
isFetchingSchema: false,
|
||||
schemaPopulated: false,
|
||||
isSchemaPopulated: false,
|
||||
schemaError: null,
|
||||
schema: {},
|
||||
isSaving: false,
|
||||
|
@ -73,7 +78,20 @@ export default {
|
|||
// Reducers
|
||||
|
||||
reducers: {
|
||||
[SET_METADATA_PROFILE_VALUE]: createSetSettingValueReducer(section)
|
||||
[SET_METADATA_PROFILE_VALUE]: createSetSettingValueReducer(section),
|
||||
|
||||
[CLONE_METADATA_PROFILE]: function(state, { payload }) {
|
||||
const id = payload.id;
|
||||
const newState = getSectionState(state, section);
|
||||
const item = newState.items.find((i) => i.id === id);
|
||||
const pendingChanges = { ...item, id: 0 };
|
||||
delete pendingChanges.id;
|
||||
|
||||
pendingChanges.name = `${pendingChanges.name} - Copy`;
|
||||
newState.pendingChanges = pendingChanges;
|
||||
|
||||
return updateSectionState(state, section, newState);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
|
|
@ -68,7 +68,7 @@ export default {
|
|||
isPopulated: false,
|
||||
error: null,
|
||||
isFetchingSchema: false,
|
||||
schemaPopulated: false,
|
||||
isSchemaPopulated: false,
|
||||
schemaError: null,
|
||||
schema: [],
|
||||
selectedSchema: {},
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { createAction } from 'redux-actions';
|
||||
import { createThunk } from 'Store/thunks';
|
||||
import getSectionState from 'Utilities/State/getSectionState';
|
||||
import updateSectionState from 'Utilities/State/updateSectionState';
|
||||
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
|
||||
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
|
||||
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
|
||||
|
@ -19,6 +21,7 @@ export const FETCH_QUALITY_PROFILE_SCHEMA = 'settings/qualityProfiles/fetchQuali
|
|||
export const SAVE_QUALITY_PROFILE = 'settings/qualityProfiles/saveQualityProfile';
|
||||
export const DELETE_QUALITY_PROFILE = 'settings/qualityProfiles/deleteQualityProfile';
|
||||
export const SET_QUALITY_PROFILE_VALUE = 'settings/qualityProfiles/setQualityProfileValue';
|
||||
export const CLONE_QUALITY_PROFILE = 'settings/qualityProfiles/cloneQualityProfile';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
@ -35,6 +38,8 @@ export const setQualityProfileValue = createAction(SET_QUALITY_PROFILE_VALUE, (p
|
|||
};
|
||||
});
|
||||
|
||||
export const cloneQualityProfile = createAction(CLONE_QUALITY_PROFILE);
|
||||
|
||||
//
|
||||
// Details
|
||||
|
||||
|
@ -50,7 +55,7 @@ export default {
|
|||
isDeleting: false,
|
||||
deleteError: null,
|
||||
isFetchingSchema: false,
|
||||
schemaPopulated: false,
|
||||
isSchemaPopulated: false,
|
||||
schemaError: null,
|
||||
schema: {},
|
||||
isSaving: false,
|
||||
|
@ -73,7 +78,20 @@ export default {
|
|||
// Reducers
|
||||
|
||||
reducers: {
|
||||
[SET_QUALITY_PROFILE_VALUE]: createSetSettingValueReducer(section)
|
||||
[SET_QUALITY_PROFILE_VALUE]: createSetSettingValueReducer(section),
|
||||
|
||||
[CLONE_QUALITY_PROFILE]: function(state, { payload }) {
|
||||
const id = payload.id;
|
||||
const newState = getSectionState(state, section);
|
||||
const item = newState.items.find((i) => i.id === id);
|
||||
const pendingChanges = { ...item, id: 0 };
|
||||
delete pendingChanges.id;
|
||||
|
||||
pendingChanges.name = `${pendingChanges.name} - Copy`;
|
||||
newState.pendingChanges = pendingChanges;
|
||||
|
||||
return updateSectionState(state, section, newState);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
|
|
@ -36,6 +36,7 @@ export const defaultState = {
|
|||
isConnected: true,
|
||||
isReconnecting: false,
|
||||
isDisconnected: false,
|
||||
isRestarting: false,
|
||||
isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen
|
||||
};
|
||||
|
||||
|
|
|
@ -3,15 +3,19 @@ import { createAction } from 'redux-actions';
|
|||
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
|
||||
import { sortDirections } from 'Helpers/Props';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import { setAppValue } from 'Store/Actions/appActions';
|
||||
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
|
||||
import createFetchHandler from './Creators/createFetchHandler';
|
||||
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
|
||||
import createHandleActions from './Creators/createHandleActions';
|
||||
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
|
||||
import { set } from './baseActions';
|
||||
|
||||
//
|
||||
// Variables
|
||||
|
||||
export const section = 'system';
|
||||
const backupsSection = 'system.backups';
|
||||
|
||||
//
|
||||
// State
|
||||
|
@ -49,6 +53,10 @@ export const defaultState = {
|
|||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
isRestoring: false,
|
||||
restoreError: null,
|
||||
isDeleting: false,
|
||||
deleteError: null,
|
||||
items: []
|
||||
},
|
||||
|
||||
|
@ -135,7 +143,12 @@ export const FETCH_DISK_SPACE = 'system/diskSpace/fetchDiskSPace';
|
|||
|
||||
export const FETCH_TASK = 'system/tasks/fetchTask';
|
||||
export const FETCH_TASKS = 'system/tasks/fetchTasks';
|
||||
|
||||
export const FETCH_BACKUPS = 'system/backups/fetchBackups';
|
||||
export const RESTORE_BACKUP = 'system/backups/restoreBackup';
|
||||
export const CLEAR_RESTORE_BACKUP = 'system/backups/clearRestoreBackup';
|
||||
export const DELETE_BACKUP = 'system/backups/deleteBackup';
|
||||
|
||||
export const FETCH_UPDATES = 'system/updates/fetchUpdates';
|
||||
|
||||
export const FETCH_LOGS = 'system/logs/fetchLogs';
|
||||
|
@ -163,7 +176,12 @@ export const fetchDiskSpace = createThunk(FETCH_DISK_SPACE);
|
|||
|
||||
export const fetchTask = createThunk(FETCH_TASK);
|
||||
export const fetchTasks = createThunk(FETCH_TASKS);
|
||||
|
||||
export const fetchBackups = createThunk(FETCH_BACKUPS);
|
||||
export const restoreBackup = createThunk(RESTORE_BACKUP);
|
||||
export const clearRestoreBackup = createAction(CLEAR_RESTORE_BACKUP);
|
||||
export const deleteBackup = createThunk(DELETE_BACKUP);
|
||||
|
||||
export const fetchUpdates = createThunk(FETCH_UPDATES);
|
||||
|
||||
export const fetchLogs = createThunk(FETCH_LOGS);
|
||||
|
@ -191,7 +209,72 @@ export const actionHandlers = handleThunks({
|
|||
[FETCH_DISK_SPACE]: createFetchHandler('system.diskSpace', '/diskspace'),
|
||||
[FETCH_TASK]: createFetchHandler('system.tasks', '/system/task'),
|
||||
[FETCH_TASKS]: createFetchHandler('system.tasks', '/system/task'),
|
||||
[FETCH_BACKUPS]: createFetchHandler('system.backups', '/system/backup'),
|
||||
|
||||
[FETCH_BACKUPS]: createFetchHandler(backupsSection, '/system/backup'),
|
||||
|
||||
[RESTORE_BACKUP]: function(getState, payload, dispatch) {
|
||||
const {
|
||||
id,
|
||||
file
|
||||
} = payload;
|
||||
|
||||
dispatch(set({
|
||||
section: backupsSection,
|
||||
isRestoring: true
|
||||
}));
|
||||
|
||||
let ajaxOptions = null;
|
||||
|
||||
if (id) {
|
||||
ajaxOptions = {
|
||||
url: `/system/backup/restore/${id}`,
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
dataType: 'json',
|
||||
data: JSON.stringify({
|
||||
id
|
||||
})
|
||||
};
|
||||
} else if (file) {
|
||||
const formData = new FormData();
|
||||
formData.append('restore', file);
|
||||
|
||||
ajaxOptions = {
|
||||
url: '/system/backup/restore/upload',
|
||||
method: 'POST',
|
||||
processData: false,
|
||||
contentType: false,
|
||||
data: formData
|
||||
};
|
||||
} else {
|
||||
dispatch(set({
|
||||
section: backupsSection,
|
||||
isRestoring: false,
|
||||
restoreError: 'Error restoring backup'
|
||||
}));
|
||||
}
|
||||
|
||||
const promise = $.ajax(ajaxOptions);
|
||||
|
||||
promise.done((data) => {
|
||||
dispatch(set({
|
||||
section: backupsSection,
|
||||
isRestoring: false,
|
||||
restoreError: null
|
||||
}));
|
||||
});
|
||||
|
||||
promise.fail((xhr) => {
|
||||
dispatch(set({
|
||||
section: backupsSection,
|
||||
isRestoring: false,
|
||||
restoreError: xhr
|
||||
}));
|
||||
});
|
||||
},
|
||||
|
||||
[DELETE_BACKUP]: createRemoveItemHandler(backupsSection, '/system/backup'),
|
||||
|
||||
[FETCH_UPDATES]: createFetchHandler('system.updates', '/update'),
|
||||
[FETCH_LOG_FILES]: createFetchHandler('system.logFiles', '/log/file'),
|
||||
[FETCH_UPDATE_LOG_FILES]: createFetchHandler('system.updateLogFiles', '/log/file/update'),
|
||||
|
@ -212,11 +295,15 @@ export const actionHandlers = handleThunks({
|
|||
}
|
||||
),
|
||||
|
||||
[RESTART]: function() {
|
||||
$.ajax({
|
||||
[RESTART]: function(getState, payload, dispatch) {
|
||||
const promise = $.ajax({
|
||||
url: '/system/restart',
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
promise.done(() => {
|
||||
dispatch(setAppValue({ isRestarting: true }));
|
||||
});
|
||||
},
|
||||
|
||||
[SHUTDOWN]: function() {
|
||||
|
@ -232,6 +319,17 @@ export const actionHandlers = handleThunks({
|
|||
|
||||
export const reducers = createHandleActions({
|
||||
|
||||
[CLEAR_RESTORE_BACKUP]: function(state, { payload }) {
|
||||
return {
|
||||
...state,
|
||||
backups: {
|
||||
...state.backups,
|
||||
isRestoring: false,
|
||||
restoreError: null
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
[SET_LOGS_TABLE_OPTION]: createSetTableOptionReducer('logs')
|
||||
|
||||
}, defaultState, section);
|
||||
|
|
|
@ -13,6 +13,7 @@ function createProviderSettingsSelector() {
|
|||
|
||||
const {
|
||||
isFetchingSchema: isFetching,
|
||||
isSchemaPopulated: isPopulated,
|
||||
schemaError: error,
|
||||
isSaving,
|
||||
saveError,
|
||||
|
@ -22,6 +23,7 @@ function createProviderSettingsSelector() {
|
|||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
|
@ -34,6 +36,7 @@ function createProviderSettingsSelector() {
|
|||
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
|
@ -45,6 +48,7 @@ function createProviderSettingsSelector() {
|
|||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
.type {
|
||||
composes: cell from 'Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
composes: cell from 'Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 70px;
|
||||
}
|
|
@ -0,0 +1,152 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Link from 'Components/Link/Link';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import RestoreBackupModalConnector from './RestoreBackupModalConnector';
|
||||
import styles from './BackupRow.css';
|
||||
|
||||
class BackupRow extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isRestoreModalOpen: false,
|
||||
isConfirmDeleteModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onRestorePress = () => {
|
||||
this.setState({ isRestoreModalOpen: true });
|
||||
}
|
||||
|
||||
onRestoreModalClose = () => {
|
||||
this.setState({ isRestoreModalOpen: false });
|
||||
}
|
||||
|
||||
onDeletePress = () => {
|
||||
this.setState({ isConfirmDeleteModalOpen: true });
|
||||
}
|
||||
|
||||
onConfirmDeleteModalClose = () => {
|
||||
this.setState({ isConfirmDeleteModalOpen: false });
|
||||
}
|
||||
|
||||
onConfirmDeletePress = () => {
|
||||
const {
|
||||
id,
|
||||
onDeleteBackupPress
|
||||
} = this.props;
|
||||
|
||||
this.setState({ isConfirmDeleteModalOpen: false }, () => {
|
||||
onDeleteBackupPress(id);
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
path,
|
||||
time
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
isRestoreModalOpen,
|
||||
isConfirmDeleteModalOpen
|
||||
} = this.state;
|
||||
|
||||
let iconClassName = icons.SCHEDULED;
|
||||
let iconTooltip = 'Scheduled';
|
||||
|
||||
if (type === 'manual') {
|
||||
iconClassName = icons.INTERACTIVE;
|
||||
iconTooltip = 'Manual';
|
||||
} else if (type === 'update') {
|
||||
iconClassName = icons.UPDATE;
|
||||
iconTooltip = 'Before update';
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow key={id}>
|
||||
<TableRowCell className={styles.type}>
|
||||
{
|
||||
<Icon
|
||||
name={iconClassName}
|
||||
title={iconTooltip}
|
||||
/>
|
||||
}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
<Link
|
||||
to={path}
|
||||
noRouter={true}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
</TableRowCell>
|
||||
|
||||
<RelativeDateCellConnector
|
||||
date={time}
|
||||
/>
|
||||
|
||||
<TableRowCell className={styles.actions}>
|
||||
<IconButton
|
||||
name={icons.RESTORE}
|
||||
onPress={this.onRestorePress}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
name={icons.DELETE}
|
||||
onPress={this.onDeletePress}
|
||||
/>
|
||||
</TableRowCell>
|
||||
|
||||
<RestoreBackupModalConnector
|
||||
isOpen={isRestoreModalOpen}
|
||||
id={id}
|
||||
name={name}
|
||||
onModalClose={this.onRestoreModalClose}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isConfirmDeleteModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title="Delete Backup"
|
||||
message={`Are you sure you want to delete the backup '${name}'?`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={this.onConfirmDeletePress}
|
||||
onCancel={this.onConfirmDeleteModalClose}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BackupRow.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
time: PropTypes.string.isRequired,
|
||||
onDeleteBackupPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default BackupRow;
|
|
@ -1,5 +0,0 @@
|
|||
.type {
|
||||
composes: cell from 'Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 20px;
|
||||
}
|
|
@ -1,20 +1,16 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
|
||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import styles from './Backups.css';
|
||||
import BackupRow from './BackupRow';
|
||||
import RestoreBackupModalConnector from './RestoreBackupModalConnector';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
|
@ -30,24 +26,53 @@ const columns = [
|
|||
name: 'time',
|
||||
label: 'Time',
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
isVisible: true
|
||||
}
|
||||
];
|
||||
|
||||
class Backups extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isRestoreModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onRestorePress = () => {
|
||||
this.setState({ isRestoreModalOpen: true });
|
||||
}
|
||||
|
||||
onRestoreModalClose = () => {
|
||||
this.setState({ isRestoreModalOpen: false });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
backupExecuting,
|
||||
onBackupPress
|
||||
onBackupPress,
|
||||
onDeleteBackupPress
|
||||
} = this.props;
|
||||
|
||||
const hasBackups = !isFetching && items.length > 0;
|
||||
const noBackups = !isFetching && !items.length;
|
||||
const hasBackups = isPopulated && !!items.length;
|
||||
const noBackups = isPopulated && !items.length;
|
||||
|
||||
return (
|
||||
<PageContent title="Backups">
|
||||
|
@ -59,15 +84,26 @@ class Backups extends Component {
|
|||
isSpinning={backupExecuting}
|
||||
onPress={onBackupPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label="Restore Backup"
|
||||
iconName={icons.RESTORE}
|
||||
onPress={this.onRestorePress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBodyConnector>
|
||||
{
|
||||
isFetching &&
|
||||
isFetching && !isPopulated &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !!error &&
|
||||
<div>Unable to load backups</div>
|
||||
}
|
||||
|
||||
{
|
||||
noBackups &&
|
||||
<div>No backups are available</div>
|
||||
|
@ -89,42 +125,16 @@ class Backups extends Component {
|
|||
time
|
||||
} = item;
|
||||
|
||||
let iconClassName = icons.SCHEDULED;
|
||||
let iconTooltip = 'Scheduled';
|
||||
|
||||
if (type === 'manual') {
|
||||
iconClassName = icons.INTERACTIVE;
|
||||
iconTooltip = 'Manual';
|
||||
} else if (item === 'update') {
|
||||
iconClassName = icons.UPDATE;
|
||||
iconTooltip = 'Before update';
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow key={id}>
|
||||
<TableRowCell className={styles.type}>
|
||||
{
|
||||
<Icon
|
||||
name={iconClassName}
|
||||
title={iconTooltip}
|
||||
/>
|
||||
}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
<Link
|
||||
to={path}
|
||||
noRouter={true}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
</TableRowCell>
|
||||
|
||||
<RelativeDateCellConnector
|
||||
className={styles.time}
|
||||
date={time}
|
||||
/>
|
||||
</TableRow>
|
||||
<BackupRow
|
||||
key={id}
|
||||
id={id}
|
||||
type={type}
|
||||
name={name}
|
||||
path={path}
|
||||
time={time}
|
||||
onDeleteBackupPress={onDeleteBackupPress}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
@ -132,6 +142,11 @@ class Backups extends Component {
|
|||
</Table>
|
||||
}
|
||||
</PageContentBodyConnector>
|
||||
|
||||
<RestoreBackupModalConnector
|
||||
isOpen={this.state.isRestoreModalOpen}
|
||||
onModalClose={this.onRestoreModalClose}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
@ -140,9 +155,12 @@ class Backups extends Component {
|
|||
|
||||
Backups.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.array.isRequired,
|
||||
backupExecuting: PropTypes.bool.isRequired,
|
||||
onBackupPress: PropTypes.func.isRequired
|
||||
onBackupPress: PropTypes.func.isRequired,
|
||||
onDeleteBackupPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default Backups;
|
||||
|
|
|
@ -4,7 +4,7 @@ import React, { Component } from 'react';
|
|||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import { fetchBackups } from 'Store/Actions/systemActions';
|
||||
import { fetchBackups, deleteBackup } from 'Store/Actions/systemActions';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import Backups from './Backups';
|
||||
|
@ -16,6 +16,8 @@ function createMapStateToProps() {
|
|||
(backups, commands) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items
|
||||
} = backups;
|
||||
|
||||
|
@ -23,6 +25,8 @@ function createMapStateToProps() {
|
|||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
backupExecuting
|
||||
};
|
||||
|
@ -30,10 +34,23 @@ function createMapStateToProps() {
|
|||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchBackups,
|
||||
executeCommand
|
||||
};
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
dispatchFetchBackups() {
|
||||
dispatch(fetchBackups());
|
||||
},
|
||||
|
||||
onDeleteBackupPress(id) {
|
||||
dispatch(deleteBackup({ id }));
|
||||
},
|
||||
|
||||
onBackupPress() {
|
||||
dispatch(executeCommand({
|
||||
name: commandNames.BACKUP
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class BackupsConnector extends Component {
|
||||
|
||||
|
@ -41,31 +58,21 @@ class BackupsConnector extends Component {
|
|||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchBackups();
|
||||
this.props.dispatchFetchBackups();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.backupExecuting && !this.props.backupExecuting) {
|
||||
this.props.fetchBackups();
|
||||
this.props.dispatchFetchBackups();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onBackupPress = () => {
|
||||
this.props.executeCommand({
|
||||
name: commandNames.BACKUP
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Backups
|
||||
onBackupPress={this.onBackupPress}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
|
@ -74,8 +81,7 @@ class BackupsConnector extends Component {
|
|||
|
||||
BackupsConnector.propTypes = {
|
||||
backupExecuting: PropTypes.bool.isRequired,
|
||||
fetchBackups: PropTypes.func.isRequired,
|
||||
executeCommand: PropTypes.func.isRequired
|
||||
dispatchFetchBackups: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(BackupsConnector);
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(BackupsConnector);
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import RestoreBackupModalContentConnector from './RestoreBackupModalContentConnector';
|
||||
|
||||
function RestoreBackupModal(props) {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<RestoreBackupModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
RestoreBackupModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default RestoreBackupModal;
|
|
@ -0,0 +1,15 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { clearRestoreBackup } from 'Store/Actions/systemActions';
|
||||
import RestoreBackupModal from './RestoreBackupModal';
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
onModalClose() {
|
||||
dispatch(clearRestoreBackup());
|
||||
|
||||
props.onModalClose();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(null, createMapDispatchToProps)(RestoreBackupModal);
|
|
@ -0,0 +1,24 @@
|
|||
.additionalInfo {
|
||||
flex-grow: 1;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.steps {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
font-size: $largeFontSize;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.stepState {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
composes: modalFooter from 'Components/Modal/ModalFooter.css';
|
||||
|
||||
flex-wrap: wrap;
|
||||
}
|
|
@ -0,0 +1,232 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import styles from './RestoreBackupModalContent.css';
|
||||
|
||||
function getErrorMessage(error) {
|
||||
if (!error || !error.responseJSON || !error.responseJSON.message) {
|
||||
return 'Error restoring backup';
|
||||
}
|
||||
|
||||
return error.responseJSON.message;
|
||||
}
|
||||
|
||||
function getStepIconProps(isExecuting, hasExecuted, error) {
|
||||
if (isExecuting) {
|
||||
return {
|
||||
name: icons.SPINNER,
|
||||
isSpinning: true
|
||||
};
|
||||
}
|
||||
|
||||
if (hasExecuted) {
|
||||
return {
|
||||
name: icons.CHECK,
|
||||
kind: kinds.SUCCESS
|
||||
};
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
name: icons.FATAL,
|
||||
kinds: kinds.DANGER,
|
||||
title: getErrorMessage(error)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: icons.PENDING
|
||||
};
|
||||
}
|
||||
|
||||
class RestoreBackupModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
file: null,
|
||||
path: '',
|
||||
isRestored: false,
|
||||
isRestarted: false,
|
||||
isReloading: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
isRestoring,
|
||||
restoreError,
|
||||
isRestarting,
|
||||
dispatchRestart
|
||||
} = this.props;
|
||||
|
||||
if (prevProps.isRestoring && !isRestoring && !restoreError) {
|
||||
this.setState({ isRestored: true }, () => {
|
||||
dispatchRestart();
|
||||
});
|
||||
}
|
||||
|
||||
if (prevProps.isRestarting && !isRestarting) {
|
||||
this.setState({
|
||||
isRestarted: true,
|
||||
isReloading: true
|
||||
}, () => {
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPathChange = ({ value, files }) => {
|
||||
this.setState({
|
||||
file: files[0],
|
||||
path: value
|
||||
});
|
||||
}
|
||||
|
||||
onRestorePress = () => {
|
||||
const {
|
||||
id,
|
||||
onRestorePress
|
||||
} = this.props;
|
||||
|
||||
onRestorePress({
|
||||
id,
|
||||
file: this.state.file
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
isRestoring,
|
||||
restoreError,
|
||||
isRestarting,
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
path,
|
||||
isRestored,
|
||||
isRestarted,
|
||||
isReloading
|
||||
} = this.state;
|
||||
|
||||
const isRestoreDisabled = (
|
||||
(!id && !path) ||
|
||||
isRestoring ||
|
||||
isRestarting ||
|
||||
isReloading
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
Restore Backup
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
{
|
||||
!!id && `Would you like to restore the backup '${name}'?`
|
||||
}
|
||||
|
||||
{
|
||||
!id &&
|
||||
<TextInput
|
||||
type="file"
|
||||
name="path"
|
||||
value={path}
|
||||
onChange={this.onPathChange}
|
||||
/>
|
||||
}
|
||||
|
||||
<div className={styles.steps}>
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepState}>
|
||||
<Icon
|
||||
size={20}
|
||||
{...getStepIconProps(isRestoring, isRestored, restoreError)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>Restore</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepState}>
|
||||
<Icon
|
||||
size={20}
|
||||
{...getStepIconProps(isRestarting, isRestarted)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>Restart</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepState}>
|
||||
<Icon
|
||||
size={20}
|
||||
{...getStepIconProps(isReloading, false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>Reload</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<div className={styles.additionalInfo}>
|
||||
Note: Lidarr will automatically restart and reload the UI during the restore process.
|
||||
</div>
|
||||
|
||||
<Button onPress={onModalClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<SpinnerButton
|
||||
kind={kinds.WARNING}
|
||||
isDisabled={isRestoreDisabled}
|
||||
isSpinning={isRestoring}
|
||||
onPress={this.onRestorePress}
|
||||
>
|
||||
Restore
|
||||
</SpinnerButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RestoreBackupModalContent.propTypes = {
|
||||
id: PropTypes.number,
|
||||
name: PropTypes.string,
|
||||
path: PropTypes.string,
|
||||
isRestoring: PropTypes.bool.isRequired,
|
||||
restoreError: PropTypes.object,
|
||||
isRestarting: PropTypes.bool.isRequired,
|
||||
dispatchRestart: PropTypes.func.isRequired,
|
||||
onRestorePress: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default RestoreBackupModalContent;
|
|
@ -0,0 +1,37 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { restoreBackup, restart } from 'Store/Actions/systemActions';
|
||||
import RestoreBackupModalContent from './RestoreBackupModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.system.backups,
|
||||
(state) => state.app.isRestarting,
|
||||
(backups, isRestarting) => {
|
||||
const {
|
||||
isRestoring,
|
||||
restoreError
|
||||
} = backups;
|
||||
|
||||
return {
|
||||
isRestoring,
|
||||
restoreError,
|
||||
isRestarting
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
onRestorePress(payload) {
|
||||
dispatch(restoreBackup(payload));
|
||||
},
|
||||
|
||||
dispatchRestart() {
|
||||
dispatch(restart());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(RestoreBackupModalContent);
|
|
@ -1,3 +1,4 @@
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using FluentValidation;
|
||||
|
@ -46,6 +47,11 @@ namespace Lidarr.Api.V1.Config
|
|||
|
||||
SharedValidator.RuleFor(c => c.Branch).NotEmpty().WithMessage("Branch name is required, 'master' is the default");
|
||||
SharedValidator.RuleFor(c => c.UpdateScriptPath).IsValidPath().When(c => c.UpdateMechanism == UpdateMechanism.Script);
|
||||
|
||||
SharedValidator.RuleFor(c => c.BackupFolder).IsValidPath().When(c => Path.IsPathRooted(c.BackupFolder));
|
||||
SharedValidator.RuleFor(c => c.BackupInterval).InclusiveBetween(1, 7);
|
||||
SharedValidator.RuleFor(c => c.BackupRetention).InclusiveBetween(1, 90);
|
||||
|
||||
}
|
||||
|
||||
private HostConfigResource GetHostConfig()
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using NzbDrone.Common.Http.Proxy;
|
||||
using NzbDrone.Common.Http.Proxy;
|
||||
using NzbDrone.Core.Authentication;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.Update;
|
||||
|
@ -33,6 +33,9 @@ namespace Lidarr.Api.V1.Config
|
|||
public string ProxyPassword { get; set; }
|
||||
public string ProxyBypassFilter { get; set; }
|
||||
public bool ProxyBypassLocalAddresses { get; set; }
|
||||
public string BackupFolder { get; set; }
|
||||
public int BackupInterval { get; set; }
|
||||
public int BackupRetention { get; set; }
|
||||
}
|
||||
|
||||
public static class HostConfigResourceMapper
|
||||
|
@ -66,7 +69,10 @@ namespace Lidarr.Api.V1.Config
|
|||
ProxyUsername = configService.ProxyUsername,
|
||||
ProxyPassword = configService.ProxyPassword,
|
||||
ProxyBypassFilter = configService.ProxyBypassFilter,
|
||||
ProxyBypassLocalAddresses = configService.ProxyBypassLocalAddresses
|
||||
ProxyBypassLocalAddresses = configService.ProxyBypassLocalAddresses,
|
||||
BackupFolder = configService.BackupFolder,
|
||||
BackupInterval = configService.BackupInterval,
|
||||
BackupRetention = configService.BackupRetention
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +1,39 @@
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Nancy;
|
||||
using NzbDrone.Common.Crypto;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Backup;
|
||||
using Lidarr.Http;
|
||||
using Lidarr.Http.Extensions;
|
||||
using Lidarr.Http.REST;
|
||||
|
||||
namespace Lidarr.Api.V1.System.Backup
|
||||
{
|
||||
public class BackupModule : LidarrRestModule<BackupResource>
|
||||
{
|
||||
private readonly IBackupService _backupService;
|
||||
private readonly IAppFolderInfo _appFolderInfo;
|
||||
private readonly IDiskProvider _diskProvider;
|
||||
|
||||
public BackupModule(IBackupService backupService) : base("system/backup")
|
||||
private static readonly List<string> ValidExtensions = new List<string> { ".zip", ".db", ".xml" };
|
||||
|
||||
public BackupModule(IBackupService backupService,
|
||||
IAppFolderInfo appFolderInfo,
|
||||
IDiskProvider diskProvider)
|
||||
: base("system/backup")
|
||||
{
|
||||
_backupService = backupService;
|
||||
_appFolderInfo = appFolderInfo;
|
||||
_diskProvider = diskProvider;
|
||||
GetResourceAll = GetBackupFiles;
|
||||
DeleteResource = DeleteBackup;
|
||||
|
||||
Post[@"/restore/(?<id>[\d]{1,10})"] = x => Restore((int)x.Id);
|
||||
Post["/restore/upload"] = x => UploadAndRestore();
|
||||
}
|
||||
|
||||
public List<BackupResource> GetBackupFiles()
|
||||
|
@ -21,15 +41,93 @@ namespace Lidarr.Api.V1.System.Backup
|
|||
var backups = _backupService.GetBackups();
|
||||
|
||||
return backups.Select(b => new BackupResource
|
||||
{
|
||||
Id = b.Name.GetHashCode(),
|
||||
Name = b.Name,
|
||||
Path = $"/backup/{b.Type.ToString().ToLower()}/{b.Name}",
|
||||
Type = b.Type,
|
||||
Time = b.Time
|
||||
})
|
||||
{
|
||||
Id = GetBackupId(b),
|
||||
Name = b.Name,
|
||||
Path = $"/backup/{b.Type.ToString().ToLower()}/{b.Name}",
|
||||
Type = b.Type,
|
||||
Time = b.Time
|
||||
})
|
||||
.OrderByDescending(b => b.Time)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void DeleteBackup(int id)
|
||||
{
|
||||
var backup = GetBackup(id);
|
||||
var path = GetBackupPath(backup);
|
||||
|
||||
if (!_diskProvider.FileExists(path))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
_diskProvider.DeleteFile(path);
|
||||
}
|
||||
|
||||
public Response Restore(int id)
|
||||
{
|
||||
var backup = GetBackup(id);
|
||||
|
||||
if (backup == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var path = GetBackupPath(backup);
|
||||
|
||||
_backupService.Restore(path);
|
||||
|
||||
return new
|
||||
{
|
||||
RestartRequired = true
|
||||
}.AsResponse();
|
||||
}
|
||||
|
||||
public Response UploadAndRestore()
|
||||
{
|
||||
var files = Context.Request.Files.ToList();
|
||||
|
||||
if (files.Empty())
|
||||
{
|
||||
throw new BadRequestException("file must be provided");
|
||||
}
|
||||
|
||||
var file = files.First();
|
||||
var extension = Path.GetExtension(file.Name);
|
||||
|
||||
if (!ValidExtensions.Contains(extension))
|
||||
{
|
||||
throw new UnsupportedMediaTypeException($"Invalid extension, must be one of: {ValidExtensions.Join(", ")}");
|
||||
}
|
||||
|
||||
var path = Path.Combine(_appFolderInfo.TempFolder, $"lidarr_backup_restore{extension}");
|
||||
|
||||
_diskProvider.SaveStream(file.Value, path);
|
||||
_backupService.Restore(path);
|
||||
|
||||
// Cleanup restored file
|
||||
_diskProvider.DeleteFile(path);
|
||||
|
||||
return new
|
||||
{
|
||||
RestartRequired = true
|
||||
}.AsResponse();
|
||||
}
|
||||
|
||||
private string GetBackupPath(NzbDrone.Core.Backup.Backup backup)
|
||||
{
|
||||
return Path.Combine(_backupService.GetBackupFolder(), backup.Type.ToString(), backup.Name);
|
||||
}
|
||||
|
||||
private int GetBackupId(NzbDrone.Core.Backup.Backup backup)
|
||||
{
|
||||
return HashConverter.GetHashInt31($"backup-{backup.Type}-{backup.Name}");
|
||||
}
|
||||
|
||||
private NzbDrone.Core.Backup.Backup GetBackup(int id)
|
||||
{
|
||||
return _backupService.GetBackups().SingleOrDefault(b => id == GetBackupId(b));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
using System.Threading.Tasks;
|
||||
using Nancy;
|
||||
using Nancy.Routing;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
|
@ -81,14 +82,14 @@ namespace Lidarr.Api.V1.System
|
|||
|
||||
private Response Shutdown()
|
||||
{
|
||||
_lifecycleService.Shutdown();
|
||||
return "".AsResponse();
|
||||
Task.Factory.StartNew(() => _lifecycleService.Shutdown());
|
||||
return new { ShuttingDown = true }.AsResponse();
|
||||
}
|
||||
|
||||
private Response Restart()
|
||||
{
|
||||
_lifecycleService.Restart();
|
||||
return "".AsResponse();
|
||||
Task.Factory.StartNew(() => _lifecycleService.Restart());
|
||||
return new { Restarting = true }.AsResponse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,31 +1,30 @@
|
|||
using System.IO;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Backup;
|
||||
|
||||
namespace Lidarr.Http.Frontend.Mappers
|
||||
{
|
||||
public class BackupFileMapper : StaticResourceMapperBase
|
||||
{
|
||||
private readonly IAppFolderInfo _appFolderInfo;
|
||||
private readonly IBackupService _backupService;
|
||||
|
||||
public BackupFileMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, Logger logger)
|
||||
public BackupFileMapper(IBackupService backupService, IDiskProvider diskProvider, Logger logger)
|
||||
: base(diskProvider, logger)
|
||||
{
|
||||
_appFolderInfo = appFolderInfo;
|
||||
_backupService = backupService;
|
||||
}
|
||||
|
||||
public override string Map(string resourceUrl)
|
||||
{
|
||||
var path = resourceUrl.Replace("/backup/", "").Replace('/', Path.DirectorySeparatorChar);
|
||||
|
||||
return Path.Combine(_appFolderInfo.GetBackupFolder(), path);
|
||||
return Path.Combine(_backupService.GetBackupFolder(), path);
|
||||
}
|
||||
|
||||
public override bool CanHandle(string resourceUrl)
|
||||
{
|
||||
return resourceUrl.StartsWith("/backup/") && resourceUrl.ContainsIgnoreCase("lidarr_backup_") && resourceUrl.EndsWith(".zip");
|
||||
return resourceUrl.StartsWith("/backup/") && BackupService.BackupFileRegex.IsMatch(resourceUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -119,6 +119,7 @@
|
|||
<Compile Include="LidarrBootstrapper.cs" />
|
||||
<Compile Include="LidarrRestModule.cs" />
|
||||
<Compile Include="LidarrRestModuleWithSignalR.cs" />
|
||||
<Compile Include="REST\UnsupportedMediaTypeException.cs" />
|
||||
<Compile Include="TinyIoCNancyBootstrapper.cs" />
|
||||
<Compile Include="Validation\EmptyCollectionValidator.cs" />
|
||||
<Compile Include="Validation\RssSyncIntervalValidator.cs" />
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
using Nancy;
|
||||
using Lidarr.Http.Exceptions;
|
||||
|
||||
namespace Lidarr.Http.REST
|
||||
{
|
||||
public class UnsupportedMediaTypeException : ApiException
|
||||
{
|
||||
public UnsupportedMediaTypeException(object content = null)
|
||||
: base(HttpStatusCode.UnsupportedMediaType, content)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
|
@ -474,5 +474,13 @@ namespace NzbDrone.Common.Disk
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveStream(Stream stream, string path)
|
||||
{
|
||||
using (var fileStream = OpenWriteStream(path))
|
||||
{
|
||||
stream.CopyTo(fileStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.AccessControl;
|
||||
|
@ -48,5 +48,6 @@ namespace NzbDrone.Common.Disk
|
|||
List<DirectoryInfo> GetDirectoryInfos(string path);
|
||||
List<FileInfo> GetFileInfos(string path);
|
||||
void RemoveEmptySubfolders(string path);
|
||||
void SaveStream(Stream stream, string path);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,10 +12,10 @@ namespace NzbDrone.Common.Extensions
|
|||
{
|
||||
private const string APP_CONFIG_FILE = "config.xml";
|
||||
private const string DB = "lidarr.db";
|
||||
private const string DB_RESTORE = "lidarr.restore";
|
||||
private const string LOG_DB = "logs.db";
|
||||
private const string NLOG_CONFIG_FILE = "nlog.config";
|
||||
private const string UPDATE_CLIENT_EXE = "Lidarr.Update.exe";
|
||||
private const string BACKUP_FOLDER = "Backups";
|
||||
|
||||
private static readonly string UPDATE_SANDBOX_FOLDER_NAME = "lidarr_update" + Path.DirectorySeparatorChar;
|
||||
private static readonly string UPDATE_PACKAGE_FOLDER_NAME = "Lidarr" + Path.DirectorySeparatorChar;
|
||||
|
@ -256,16 +256,16 @@ namespace NzbDrone.Common.Extensions
|
|||
return Path.Combine(GetUpdateSandboxFolder(appFolderInfo), UPDATE_CLIENT_EXE);
|
||||
}
|
||||
|
||||
public static string GetBackupFolder(this IAppFolderInfo appFolderInfo)
|
||||
{
|
||||
return Path.Combine(GetAppDataPath(appFolderInfo), BACKUP_FOLDER);
|
||||
}
|
||||
|
||||
public static string GetDatabase(this IAppFolderInfo appFolderInfo)
|
||||
{
|
||||
return Path.Combine(GetAppDataPath(appFolderInfo), DB);
|
||||
}
|
||||
|
||||
public static string GetDatabaseRestore(this IAppFolderInfo appFolderInfo)
|
||||
{
|
||||
return Path.Combine(GetAppDataPath(appFolderInfo), DB_RESTORE);
|
||||
}
|
||||
|
||||
public static string GetLogDatabase(this IAppFolderInfo appFolderInfo)
|
||||
{
|
||||
return Path.Combine(GetAppDataPath(appFolderInfo), LOG_DB);
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Data.SQLite;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Marr.Data;
|
||||
using NLog;
|
||||
using NzbDrone.Common;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Instrumentation.Extensions;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.Messaging.Commands;
|
||||
|
||||
|
@ -21,6 +21,8 @@ namespace NzbDrone.Core.Backup
|
|||
{
|
||||
void Backup(BackupType backupType);
|
||||
List<Backup> GetBackups();
|
||||
void Restore(string backupFileName);
|
||||
string GetBackupFolder();
|
||||
}
|
||||
|
||||
public class BackupService : IBackupService, IExecute<BackupCommand>
|
||||
|
@ -31,11 +33,12 @@ namespace NzbDrone.Core.Backup
|
|||
private readonly IDiskProvider _diskProvider;
|
||||
private readonly IAppFolderInfo _appFolderInfo;
|
||||
private readonly IArchiveService _archiveService;
|
||||
private readonly IConfigService _configService;
|
||||
private readonly Logger _logger;
|
||||
|
||||
private string _backupTempFolder;
|
||||
|
||||
private static readonly Regex BackupFileRegex = new Regex(@"lidarr_backup_[._0-9]+\.zip", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
public static readonly Regex BackupFileRegex = new Regex(@"lidarr_backup_[._0-9]+\.zip", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public BackupService(IMainDatabase maindDb,
|
||||
IMakeDatabaseBackup makeDatabaseBackup,
|
||||
|
@ -43,6 +46,7 @@ namespace NzbDrone.Core.Backup
|
|||
IDiskProvider diskProvider,
|
||||
IAppFolderInfo appFolderInfo,
|
||||
IArchiveService archiveService,
|
||||
IConfigService configService,
|
||||
Logger logger)
|
||||
{
|
||||
_maindDb = maindDb;
|
||||
|
@ -51,6 +55,7 @@ namespace NzbDrone.Core.Backup
|
|||
_diskProvider = diskProvider;
|
||||
_appFolderInfo = appFolderInfo;
|
||||
_archiveService = archiveService;
|
||||
_configService = configService;
|
||||
_logger = logger;
|
||||
|
||||
_backupTempFolder = Path.Combine(_appFolderInfo.TempFolder, "lidarr_backup");
|
||||
|
@ -75,9 +80,15 @@ namespace NzbDrone.Core.Backup
|
|||
|
||||
BackupConfigFile();
|
||||
BackupDatabase();
|
||||
CreateVersionInfo();
|
||||
|
||||
_logger.ProgressDebug("Creating backup zip");
|
||||
|
||||
// Delete journal file created during database backup
|
||||
_diskProvider.DeleteFile(Path.Combine(_backupTempFolder, "lidarr.db-journal"));
|
||||
|
||||
_archiveService.CreateZip(backupPath, _diskProvider.GetFiles(_backupTempFolder, SearchOption.TopDirectoryOnly));
|
||||
|
||||
_logger.ProgressDebug("Backup zip created");
|
||||
}
|
||||
|
||||
|
@ -103,6 +114,62 @@ namespace NzbDrone.Core.Backup
|
|||
return backups;
|
||||
}
|
||||
|
||||
public void Restore(string backupFileName)
|
||||
{
|
||||
if (backupFileName.EndsWith(".zip"))
|
||||
{
|
||||
var restoredFile = false;
|
||||
var temporaryPath = Path.Combine(_appFolderInfo.TempFolder, "lidarr_backup_restore");
|
||||
|
||||
_archiveService.Extract(backupFileName, temporaryPath);
|
||||
|
||||
foreach (var file in _diskProvider.GetFiles(temporaryPath, SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
var fileName = Path.GetFileName(file);
|
||||
|
||||
if (fileName.Equals("Config.xml", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
_diskProvider.MoveFile(file, _appFolderInfo.GetConfigPath(), true);
|
||||
restoredFile = true;
|
||||
}
|
||||
|
||||
if (fileName.Equals("lidarr.db", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
_diskProvider.MoveFile(file, _appFolderInfo.GetDatabaseRestore(), true);
|
||||
restoredFile = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!restoredFile)
|
||||
{
|
||||
throw new RestoreBackupFailedException(HttpStatusCode.NotFound, "Unable to restore database file from backup");
|
||||
}
|
||||
|
||||
_diskProvider.DeleteFolder(temporaryPath, true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_diskProvider.MoveFile(backupFileName, _appFolderInfo.GetDatabaseRestore(), true);
|
||||
}
|
||||
|
||||
public string GetBackupFolder()
|
||||
{
|
||||
var backupFolder = _configService.BackupFolder;
|
||||
|
||||
if (Path.IsPathRooted(backupFolder))
|
||||
{
|
||||
return backupFolder;
|
||||
}
|
||||
|
||||
return Path.Combine(_appFolderInfo.GetAppDataPath(), backupFolder);
|
||||
}
|
||||
|
||||
private string GetBackupFolder(BackupType backupType)
|
||||
{
|
||||
return Path.Combine(GetBackupFolder(), backupType.ToString().ToLower());
|
||||
}
|
||||
|
||||
private void Cleanup()
|
||||
{
|
||||
if (_diskProvider.FolderExists(_backupTempFolder))
|
||||
|
@ -128,16 +195,25 @@ namespace NzbDrone.Core.Backup
|
|||
_diskTransferService.TransferFile(configFile, tempConfigFile, TransferMode.Copy);
|
||||
}
|
||||
|
||||
private void CreateVersionInfo()
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
builder.AppendLine(BuildInfo.Version.ToString());
|
||||
}
|
||||
|
||||
private void CleanupOldBackups(BackupType backupType)
|
||||
{
|
||||
_logger.Debug("Cleaning up old backup files");
|
||||
var retention = _configService.BackupRetention;
|
||||
|
||||
_logger.Debug("Cleaning up backup files older than {0} days", retention);
|
||||
var files = GetBackupFiles(GetBackupFolder(backupType));
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var lastWriteTime = _diskProvider.FileGetLastWrite(file);
|
||||
|
||||
if (lastWriteTime.AddDays(28) < DateTime.UtcNow)
|
||||
if (lastWriteTime.AddDays(retention) < DateTime.UtcNow)
|
||||
{
|
||||
_logger.Debug("Deleting old backup file: {0}", file);
|
||||
_diskProvider.DeleteFile(file);
|
||||
|
@ -147,11 +223,6 @@ namespace NzbDrone.Core.Backup
|
|||
_logger.Debug("Finished cleaning up old backup files");
|
||||
}
|
||||
|
||||
private string GetBackupFolder(BackupType backupType)
|
||||
{
|
||||
return Path.Combine(_appFolderInfo.GetBackupFolder(), backupType.ToString().ToLower());
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetBackupFiles(string path)
|
||||
{
|
||||
var files = _diskProvider.GetFiles(path, SearchOption.TopDirectoryOnly);
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
using System.Net;
|
||||
using NzbDrone.Core.Exceptions;
|
||||
|
||||
namespace NzbDrone.Core.Backup
|
||||
{
|
||||
public class RestoreBackupFailedException : NzbDroneClientException
|
||||
{
|
||||
public RestoreBackupFailedException(HttpStatusCode statusCode, string message, params object[] args) : base(statusCode, message, args)
|
||||
{
|
||||
}
|
||||
|
||||
public RestoreBackupFailedException(HttpStatusCode statusCode, string message) : base(statusCode, message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,7 +4,6 @@ using System.Globalization;
|
|||
using System.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common.EnsureThat;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Core.Configuration.Events;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
|
@ -332,6 +331,12 @@ namespace NzbDrone.Core.Configuration
|
|||
|
||||
public bool ProxyBypassLocalAddresses => GetValueBoolean("ProxyBypassLocalAddresses", true);
|
||||
|
||||
public string BackupFolder => GetValue("BackupFolder", "Backups");
|
||||
|
||||
public int BackupInterval => GetValueInt("BackupInterval", 7);
|
||||
|
||||
public int BackupRetention => GetValueInt("BackupRetention", 28);
|
||||
|
||||
private string GetValue(string key)
|
||||
{
|
||||
return GetValue(key, string.Empty);
|
||||
|
|
|
@ -78,5 +78,11 @@ namespace NzbDrone.Core.Configuration
|
|||
string ProxyPassword { get; }
|
||||
string ProxyBypassFilter { get; }
|
||||
bool ProxyBypassLocalAddresses { get; }
|
||||
|
||||
// Backups
|
||||
string BackupFolder { get; }
|
||||
int BackupInterval { get; }
|
||||
int BackupRetention { get; }
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
using System;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Instrumentation;
|
||||
|
||||
namespace NzbDrone.Core.Datastore
|
||||
{
|
||||
public interface IRestoreDatabase
|
||||
{
|
||||
void Restore();
|
||||
}
|
||||
|
||||
public class DatabaseRestorationService : IRestoreDatabase
|
||||
{
|
||||
private readonly IDiskProvider _diskProvider;
|
||||
private readonly IAppFolderInfo _appFolderInfo;
|
||||
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(DatabaseRestorationService));
|
||||
|
||||
public DatabaseRestorationService(IDiskProvider diskProvider, IAppFolderInfo appFolderInfo)
|
||||
{
|
||||
_diskProvider = diskProvider;
|
||||
_appFolderInfo = appFolderInfo;
|
||||
}
|
||||
|
||||
public void Restore()
|
||||
{
|
||||
var dbRestorePath = _appFolderInfo.GetDatabaseRestore();
|
||||
|
||||
if (!_diskProvider.FileExists(dbRestorePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Logger.Info("Restoring Database");
|
||||
|
||||
var dbPath = _appFolderInfo.GetDatabase();
|
||||
|
||||
_diskProvider.DeleteFile(dbPath + "-shm");
|
||||
_diskProvider.DeleteFile(dbPath + "-wal");
|
||||
_diskProvider.DeleteFile(dbPath + "-journal");
|
||||
_diskProvider.DeleteFile(dbPath);
|
||||
|
||||
_diskProvider.MoveFile(dbRestorePath, dbPath);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.Error(e, "Failed to restore database");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -24,6 +24,7 @@ namespace NzbDrone.Core.Datastore
|
|||
private readonly IMigrationController _migrationController;
|
||||
private readonly IConnectionStringFactory _connectionStringFactory;
|
||||
private readonly IDiskProvider _diskProvider;
|
||||
private readonly IRestoreDatabase _restoreDatabaseService;
|
||||
|
||||
static DbFactory()
|
||||
{
|
||||
|
@ -44,11 +45,13 @@ namespace NzbDrone.Core.Datastore
|
|||
|
||||
public DbFactory(IMigrationController migrationController,
|
||||
IConnectionStringFactory connectionStringFactory,
|
||||
IDiskProvider diskProvider)
|
||||
IDiskProvider diskProvider,
|
||||
IRestoreDatabase restoreDatabaseService)
|
||||
{
|
||||
_migrationController = migrationController;
|
||||
_connectionStringFactory = connectionStringFactory;
|
||||
_diskProvider = diskProvider;
|
||||
_restoreDatabaseService = restoreDatabaseService;
|
||||
}
|
||||
|
||||
public IDatabase Create(MigrationType migrationType = MigrationType.Main)
|
||||
|
@ -59,18 +62,21 @@ namespace NzbDrone.Core.Datastore
|
|||
public IDatabase Create(MigrationContext migrationContext)
|
||||
{
|
||||
string connectionString;
|
||||
|
||||
|
||||
|
||||
switch (migrationContext.MigrationType)
|
||||
{
|
||||
case MigrationType.Main:
|
||||
{
|
||||
connectionString = _connectionStringFactory.MainDbConnectionString;
|
||||
CreateMain(connectionString, migrationContext);
|
||||
|
||||
break;
|
||||
}
|
||||
case MigrationType.Log:
|
||||
{
|
||||
connectionString = _connectionStringFactory.LogDbConnectionString;
|
||||
CreateLog(connectionString, migrationContext);
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
@ -79,53 +85,15 @@ namespace NzbDrone.Core.Datastore
|
|||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_migrationController.Migrate(connectionString, migrationContext);
|
||||
}
|
||||
catch (SQLiteException ex)
|
||||
{
|
||||
var fileName = _connectionStringFactory.GetDatabasePath(connectionString);
|
||||
|
||||
if (migrationContext.MigrationType == MigrationType.Log)
|
||||
{
|
||||
Logger.Error(ex, "Logging database is corrupt, attempting to recreate it automatically");
|
||||
|
||||
try
|
||||
{
|
||||
_diskProvider.DeleteFile(fileName + "-shm");
|
||||
_diskProvider.DeleteFile(fileName + "-wal");
|
||||
_diskProvider.DeleteFile(fileName + "-journal");
|
||||
_diskProvider.DeleteFile(fileName);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Logger.Error("Unable to recreate logging database automatically. It will need to be removed manually.");
|
||||
}
|
||||
|
||||
_migrationController.Migrate(connectionString, migrationContext);
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
if (OsInfo.IsOsx)
|
||||
{
|
||||
throw new CorruptDatabaseException("Database file: {0} is corrupt, restore from backup if available. See: https://github.com/Lidarr/Lidarr/wiki/FAQ#i-use-Lidarr-on-a-mac-and-it-suddenly-stopped-working-what-happened", ex, fileName);
|
||||
}
|
||||
|
||||
throw new CorruptDatabaseException("Database file: {0} is corrupt, restore from backup if available. See: https://github.com/Lidarr/Lidarr/wiki/FAQ#i-am-getting-an-error-database-disk-image-is-malformed", ex, fileName);
|
||||
}
|
||||
}
|
||||
|
||||
var db = new Database(migrationContext.MigrationType.ToString(), () =>
|
||||
{
|
||||
var dataMapper = new DataMapper(SQLiteFactory.Instance, connectionString)
|
||||
{
|
||||
var dataMapper = new DataMapper(SQLiteFactory.Instance, connectionString)
|
||||
{
|
||||
SqlMode = SqlModes.Text,
|
||||
};
|
||||
SqlMode = SqlModes.Text,
|
||||
};
|
||||
|
||||
return dataMapper;
|
||||
});
|
||||
return dataMapper;
|
||||
});
|
||||
|
||||
if (db.Migration > 100) //Quick DB Migration Check. This should get rid of users on old DB format
|
||||
{
|
||||
|
@ -134,5 +102,54 @@ namespace NzbDrone.Core.Datastore
|
|||
|
||||
return db;
|
||||
}
|
||||
|
||||
private void CreateMain(string connectionString, MigrationContext migrationContext)
|
||||
{
|
||||
|
||||
try
|
||||
{
|
||||
_restoreDatabaseService.Restore();
|
||||
_migrationController.Migrate(connectionString, migrationContext);
|
||||
}
|
||||
catch (SQLiteException e)
|
||||
{
|
||||
var fileName = _connectionStringFactory.GetDatabasePath(connectionString);
|
||||
|
||||
if (OsInfo.IsOsx)
|
||||
{
|
||||
throw new CorruptDatabaseException("Database file: {0} is corrupt, restore from backup if available. See: https://github.com/Sonarr/Sonarr/wiki/FAQ#i-use-sonarr-on-a-mac-and-it-suddenly-stopped-working-what-happened", e, fileName);
|
||||
}
|
||||
|
||||
throw new CorruptDatabaseException("Database file: {0} is corrupt, restore from backup if available. See: https://github.com/Sonarr/Sonarr/wiki/FAQ#i-am-getting-an-error-database-disk-image-is-malformed", e, fileName);
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateLog(string connectionString, MigrationContext migrationContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
_migrationController.Migrate(connectionString, migrationContext);
|
||||
}
|
||||
catch (SQLiteException e)
|
||||
{
|
||||
var fileName = _connectionStringFactory.GetDatabasePath(connectionString);
|
||||
|
||||
Logger.Error(e, "Logging database is corrupt, attempting to recreate it automatically");
|
||||
|
||||
try
|
||||
{
|
||||
_diskProvider.DeleteFile(fileName + "-shm");
|
||||
_diskProvider.DeleteFile(fileName + "-wal");
|
||||
_diskProvider.DeleteFile(fileName + "-journal");
|
||||
_diskProvider.DeleteFile(fileName);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Logger.Error("Unable to recreate logging database automatically. It will need to be removed manually.");
|
||||
}
|
||||
|
||||
_migrationController.Migrate(connectionString, migrationContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,7 +65,12 @@ namespace NzbDrone.Core.Jobs
|
|||
new ScheduledTask{ Interval = 6*60, TypeName = typeof(CheckHealthCommand).FullName},
|
||||
new ScheduledTask{ Interval = 12*60, TypeName = typeof(RefreshArtistCommand).FullName},
|
||||
new ScheduledTask{ Interval = 24*60, TypeName = typeof(HousekeepingCommand).FullName},
|
||||
new ScheduledTask{ Interval = 7*24*60, TypeName = typeof(BackupCommand).FullName},
|
||||
|
||||
new ScheduledTask
|
||||
{
|
||||
Interval = GetBackupInterval(),
|
||||
TypeName = typeof(BackupCommand).FullName
|
||||
},
|
||||
|
||||
new ScheduledTask
|
||||
{
|
||||
|
@ -102,6 +107,13 @@ namespace NzbDrone.Core.Jobs
|
|||
}
|
||||
}
|
||||
|
||||
private int GetBackupInterval()
|
||||
{
|
||||
var interval = _configService.BackupInterval;
|
||||
|
||||
return interval * 60 * 24;
|
||||
}
|
||||
|
||||
private int GetRssSyncInterval()
|
||||
{
|
||||
var interval = _configService.RssSyncInterval;
|
||||
|
|
|
@ -128,6 +128,7 @@
|
|||
<Compile Include="Backup\BackupCommand.cs" />
|
||||
<Compile Include="Backup\BackupService.cs" />
|
||||
<Compile Include="Backup\MakeDatabaseBackup.cs" />
|
||||
<Compile Include="Backup\RestoreBackupFailedException.cs" />
|
||||
<Compile Include="Blacklisting\Blacklist.cs" />
|
||||
<Compile Include="Blacklisting\BlacklistRepository.cs" />
|
||||
<Compile Include="Blacklisting\BlacklistService.cs" />
|
||||
|
@ -163,6 +164,7 @@
|
|||
<Compile Include="Datastore\Converters\UtcConverter.cs" />
|
||||
<Compile Include="Datastore\CorruptDatabaseException.cs" />
|
||||
<Compile Include="Datastore\Database.cs" />
|
||||
<Compile Include="Datastore\DatabaseRestorationService.cs" />
|
||||
<Compile Include="Datastore\DbFactory.cs" />
|
||||
<Compile Include="Datastore\Events\ModelEvent.cs" />
|
||||
<Compile Include="Datastore\Extensions\MappingExtensions.cs" />
|
||||
|
|
|
@ -90,7 +90,5 @@ namespace NzbDrone.Host
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue