mirror of
https://github.com/Sonarr/Sonarr.git
synced 2024-11-24 08:42:19 +02:00
Implemented experimental Script Console for debugging with editor in the diag ui.
This commit is contained in:
parent
031371652b
commit
94f8e38d5a
@ -3,7 +3,7 @@
|
||||
# Uncomment this to turn on verbose mode.
|
||||
#export DH_VERBOSE=1
|
||||
|
||||
EXCLUDE_MODULEREFS = crypt32 httpapi __Internal ole32.dll libmonosgen-2.0
|
||||
EXCLUDE_MODULEREFS = crypt32 httpapi __Internal ole32.dll libmonosgen-2.0 clr mscorlib mscoree.dll Microsoft.DiaSymReader.Native.x86.dll Microsoft.DiaSymReader.Native.amd64.dll
|
||||
|
||||
%:
|
||||
dh $@ --with=systemd --with=cli
|
||||
|
@ -119,6 +119,11 @@ const config = {
|
||||
rules: [
|
||||
{
|
||||
test: /\.worker\.js$/,
|
||||
issuer: {
|
||||
// monaco-editor includes the editor.worker.js in other language workers,
|
||||
// don't use worker-loader in that case
|
||||
exclude: /monaco-editor/
|
||||
},
|
||||
use: {
|
||||
loader: 'worker-loader',
|
||||
options: {
|
||||
|
@ -33,6 +33,7 @@ import BackupsConnector from 'System/Backup/BackupsConnector';
|
||||
import UpdatesConnector from 'System/Updates/UpdatesConnector';
|
||||
import LogsTableConnector from 'System/Events/LogsTableConnector';
|
||||
import Logs from 'System/Logs/Logs';
|
||||
import Diagnostic from 'Diagnostic/Diagnostic';
|
||||
|
||||
function AppRoutes(props) {
|
||||
const {
|
||||
@ -229,6 +230,15 @@ function AppRoutes(props) {
|
||||
component={Logs}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Diagnostics
|
||||
*/}
|
||||
|
||||
<Route
|
||||
path="/diag"
|
||||
component={Diagnostic}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Not Found
|
||||
*/}
|
||||
|
@ -165,6 +165,23 @@ const links = [
|
||||
to: '/system/logs/files'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
iconName: icons.DEBUG,
|
||||
hidden: true,
|
||||
title: 'Diagnostics',
|
||||
to: '/diag/status',
|
||||
children: [
|
||||
{
|
||||
title: 'Status',
|
||||
to: '/diag/status'
|
||||
},
|
||||
{
|
||||
title: 'Script Console',
|
||||
to: '/diag/script'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@ -473,6 +490,10 @@ class PageSidebar extends Component {
|
||||
const isActiveParent = activeParent === link.to;
|
||||
const hasActiveChild = hasActiveChildLink(link, pathname);
|
||||
|
||||
if (link.hidden && !isActiveParent && !hasActiveChild) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSidebarItem
|
||||
key={link.to}
|
||||
|
@ -41,6 +41,7 @@ function PageToolbarButton(props) {
|
||||
}
|
||||
|
||||
PageToolbarButton.propTypes = {
|
||||
...Link.propTypes,
|
||||
label: PropTypes.string.isRequired,
|
||||
iconName: PropTypes.object.isRequired,
|
||||
spinningName: PropTypes.object,
|
||||
|
44
frontend/src/Diagnostic/Diagnostic.js
Normal file
44
frontend/src/Diagnostic/Diagnostic.js
Normal file
@ -0,0 +1,44 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Route, Redirect } from 'react-router-dom';
|
||||
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
|
||||
import Switch from 'Components/Router/Switch';
|
||||
import StatusConnector from './Status/StatusConnector';
|
||||
import ScriptConnector from './Script/ScriptConnector';
|
||||
|
||||
class Diagnostic extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
exact={true}
|
||||
path="/diag/status"
|
||||
component={StatusConnector}
|
||||
/>
|
||||
<Route
|
||||
exact={true}
|
||||
path="/diag/script"
|
||||
component={ScriptConnector}
|
||||
/>
|
||||
|
||||
{/* Redirect root to status */}
|
||||
<Route
|
||||
exact={true}
|
||||
path="/diag"
|
||||
render={() => {
|
||||
return (
|
||||
<Redirect
|
||||
to={getPathWithUrlBase('/diag/status')}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Diagnostic;
|
82
frontend/src/Diagnostic/Script/MonacoEditor.js
Normal file
82
frontend/src/Diagnostic/Script/MonacoEditor.js
Normal file
@ -0,0 +1,82 @@
|
||||
import ReactMonacoEditor from 'react-monaco-editor';
|
||||
import shallowEqual from 'shallowequal';
|
||||
|
||||
// All editor features -> 7.56 MiB
|
||||
// import 'monaco-editor/esm/vs/editor/editor.all';
|
||||
|
||||
// Only the needed editor features -> 6.88 MiB
|
||||
import 'monaco-editor/esm/vs/editor/browser/controller/coreCommands';
|
||||
import 'monaco-editor/esm/vs/editor/browser/widget/codeEditorWidget';
|
||||
// import 'monaco-editor/esm/vs/editor/browser/widget/diffEditorWidget';
|
||||
// import 'monaco-editor/esm/vs/editor/browser/widget/diffNavigator';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/bracketMatching/bracketMatching';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/caretOperations/caretOperations';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/caretOperations/transpose';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/clipboard/clipboard';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/codeAction/codeActionContributions';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/codelens/codelensController';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/colorPicker/colorDetector';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/comment/comment';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/contextmenu/contextmenu';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/cursorUndo/cursorUndo';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/dnd/dnd';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/find/findController';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/folding/folding';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/fontZoom/fontZoom';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/format/formatActions';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/goToDefinition/goToDefinitionCommands';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/goToDefinition/goToDefinitionMouse';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/gotoError/gotoError';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/hover/hover';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/inPlaceReplace/inPlaceReplace';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/linesOperations/linesOperations';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/links/links';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/multicursor/multicursor';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/parameterHints/parameterHints';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/referenceSearch/referenceSearch';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/rename/rename';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/smartSelect/smartSelect';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/snippet/snippetController2';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/suggest/suggestController';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/tokenization/tokenization';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/toggleTabFocusMode/toggleTabFocusMode';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/wordHighlighter/wordHighlighter';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/wordOperations/wordOperations';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/wordPartOperations/wordPartOperations';
|
||||
|
||||
// csharp&json language
|
||||
import 'monaco-editor/esm/vs/basic-languages/csharp/csharp';
|
||||
import 'monaco-editor/esm/vs/basic-languages/csharp/csharp.contribution';
|
||||
import 'monaco-editor/esm/vs/language/json/monaco.contribution';
|
||||
import 'monaco-editor/esm/vs/language/json/jsonWorker';
|
||||
import 'monaco-editor/esm/vs/language/json/jsonMode';
|
||||
|
||||
// Create a WebWorker from a blob rather than an url
|
||||
import * as EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker';
|
||||
import * as JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker';
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker: (moduleId, label) => {
|
||||
if (label === 'editorWorkerService') {
|
||||
return new EditorWorker();
|
||||
}
|
||||
if (label === 'json') {
|
||||
return new JsonWorker();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
class MonacoEditor extends ReactMonacoEditor {
|
||||
|
||||
// ReactMonacoEditor should've been PureComponent
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
if (!shallowEqual(this.props, nextProps)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default MonacoEditor;
|
93
frontend/src/Diagnostic/Script/ScriptConnector.js
Normal file
93
frontend/src/Diagnostic/Script/ScriptConnector.js
Normal file
@ -0,0 +1,93 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchStatus, updateScript, validateScript, executeScript } from 'Store/Actions/diagnosticActions';
|
||||
import ScriptConsole from './ScriptConsole';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Alert from 'Components/Alert';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.diagnostic,
|
||||
(diag) => {
|
||||
return {
|
||||
isStatusPopulated: diag.status.isPopulated,
|
||||
isScriptConsoleEnabled: diag.status.item.scriptConsoleEnabled,
|
||||
isExecuting: diag.script.isExecuting || false,
|
||||
isDebugging: diag.script.isDebugging || false,
|
||||
isValidating: diag.script.isValidating,
|
||||
code: diag.script.code,
|
||||
result: diag.script.result,
|
||||
validation: diag.script.validation,
|
||||
error: diag.script.error
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchStatus,
|
||||
updateScript,
|
||||
validateScript,
|
||||
executeScript
|
||||
};
|
||||
|
||||
class ScriptConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.isStatusPopulated) {
|
||||
this.props.fetchStatus();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
if (!this.props.isStatusPopulated) {
|
||||
return (
|
||||
<PageContent>
|
||||
<LoadingIndicator />
|
||||
</PageContent>
|
||||
);
|
||||
} else if (!this.props.isScriptConsoleEnabled) {
|
||||
return (
|
||||
<PageContent>
|
||||
<Alert kind={kinds.WARNING}>
|
||||
Diagnostic Scripting is disabled
|
||||
</Alert>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScriptConsole
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ScriptConnector.propTypes = {
|
||||
isStatusPopulated: PropTypes.bool.isRequired,
|
||||
isScriptConsoleEnabled: PropTypes.bool,
|
||||
isExecuting: PropTypes.bool.isRequired,
|
||||
isDebugging: PropTypes.bool.isRequired,
|
||||
isValidating: PropTypes.bool.isRequired,
|
||||
code: PropTypes.string,
|
||||
result: PropTypes.object,
|
||||
error: PropTypes.object,
|
||||
validation: PropTypes.object,
|
||||
fetchStatus: PropTypes.func.isRequired,
|
||||
updateScript: PropTypes.func.isRequired,
|
||||
validateScript: PropTypes.func.isRequired,
|
||||
executeScript: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(ScriptConnector);
|
6
frontend/src/Diagnostic/Script/ScriptConsole.css
Normal file
6
frontend/src/Diagnostic/Script/ScriptConsole.css
Normal file
@ -0,0 +1,6 @@
|
||||
.split {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
139
frontend/src/Diagnostic/Script/ScriptConsole.js
Normal file
139
frontend/src/Diagnostic/Script/ScriptConsole.js
Normal file
@ -0,0 +1,139 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component, lazy, Suspense } from 'react';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import styles from './ScriptConsole.css';
|
||||
|
||||
// Lazy load the Monaco Editor since it's a big bundle
|
||||
const MonacoEditor = lazy(() => import(/* webpackChunkName: "monaco-editor" */ './MonacoEditor'));
|
||||
|
||||
const DefaultOptions = {
|
||||
selectOnLineNumbers: true,
|
||||
scrollBeyondLastLine: false
|
||||
};
|
||||
const DefaultResultOptions = {
|
||||
...DefaultOptions,
|
||||
readOnly: true
|
||||
};
|
||||
|
||||
class ScriptConsole extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
editorDidMount = (editor, monaco) => {
|
||||
console.log('editorDidMount', editor);
|
||||
editor.focus();
|
||||
this.monaco = monaco;
|
||||
this.editor = editor;
|
||||
|
||||
this.updateValidation(this.props.validation);
|
||||
}
|
||||
|
||||
updateValidation(validation) {
|
||||
if (!this.monaco) {
|
||||
return;
|
||||
}
|
||||
|
||||
let diagnostics = [];
|
||||
|
||||
if (validation && validation.errorDiagnostics) {
|
||||
diagnostics = validation.errorDiagnostics;
|
||||
}
|
||||
|
||||
const model = this.editor.getModel();
|
||||
|
||||
this.monaco.editor.setModelMarkers(model, 'editor', diagnostics);
|
||||
}
|
||||
|
||||
onChange = (newValue, e) => {
|
||||
this.props.updateScript({ code: newValue });
|
||||
|
||||
this.validateCode();
|
||||
}
|
||||
|
||||
validateCode = _.debounce(() => {
|
||||
const code = this.props.code;
|
||||
this.props.validateScript({ code });
|
||||
}, 250, { leading: false, trailing: true })
|
||||
|
||||
onExecuteScriptPress = () => {
|
||||
const code = this.props.code;
|
||||
this.props.executeScript({ code });
|
||||
}
|
||||
|
||||
onDebugScriptPress = () => {
|
||||
const code = this.props.code;
|
||||
this.props.executeScript({ code, debug: true });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
render() {
|
||||
const code = this.props.code;
|
||||
const result = JSON.stringify(this.props.result, null, 2);
|
||||
|
||||
this.updateValidation(this.props.validation);
|
||||
|
||||
return (
|
||||
<PageContent>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label="Run"
|
||||
iconName={this.props.isExecuting ? icons.REFRESH : icons.SCRIPT_RUN}
|
||||
isSpinning={this.props.isExecuting}
|
||||
onPress={this.onExecuteScriptPress}
|
||||
/>
|
||||
<PageToolbarButton
|
||||
label="Debug"
|
||||
iconName={this.props.isDebugging ? icons.REFRESH : icons.SCRIPT_DEBUG}
|
||||
isSpinning={this.props.isDebugging}
|
||||
onPress={this.onDebugScriptPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
<Suspense fallback={<LoadingIndicator />}>
|
||||
<div className={styles.split}>
|
||||
<MonacoEditor
|
||||
language="csharp"
|
||||
theme="vs-light"
|
||||
width="50%"
|
||||
value={code}
|
||||
options={DefaultOptions}
|
||||
onChange={this.onChange}
|
||||
editorDidMount={this.editorDidMount}
|
||||
/>
|
||||
<MonacoEditor
|
||||
language="json"
|
||||
theme="vs-light"
|
||||
width="50%"
|
||||
value={result}
|
||||
options={DefaultResultOptions}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ScriptConsole.propTypes = {
|
||||
isExecuting: PropTypes.bool.isRequired,
|
||||
isDebugging: PropTypes.bool.isRequired,
|
||||
isValidating: PropTypes.bool.isRequired,
|
||||
code: PropTypes.string,
|
||||
result: PropTypes.object,
|
||||
error: PropTypes.object,
|
||||
validation: PropTypes.object,
|
||||
updateScript: PropTypes.func.isRequired,
|
||||
validateScript: PropTypes.func.isRequired,
|
||||
executeScript: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ScriptConsole;
|
5
frontend/src/Diagnostic/Status/Statistics/Statistics.css
Normal file
5
frontend/src/Diagnostic/Status/Statistics/Statistics.css
Normal file
@ -0,0 +1,5 @@
|
||||
.descriptionList {
|
||||
composes: descriptionList from '~Components/DescriptionList/DescriptionList.css';
|
||||
|
||||
margin-bottom: 10px;
|
||||
}
|
93
frontend/src/Diagnostic/Status/Statistics/Statistics.js
Normal file
93
frontend/src/Diagnostic/Status/Statistics/Statistics.js
Normal file
@ -0,0 +1,93 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||
import styles from './Statistics.css';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
|
||||
import moment from 'moment';
|
||||
|
||||
function formatValue(val, formatter) {
|
||||
if (val === undefined) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
if (formatter) {
|
||||
return formatter(val);
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
class Statistics extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
process,
|
||||
databaseMain,
|
||||
databaseLog,
|
||||
commandsExecuted
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<FieldSet legend="Statistics">
|
||||
<DescriptionList className={styles.descriptionList}>
|
||||
<DescriptionListItem
|
||||
title="Up Time"
|
||||
data={formatValue(process.startTime, (startTime) => formatTimeSpan(moment().diff(startTime)))}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
title="Processor Time"
|
||||
data={formatValue(process.totalProcessorTime, formatTimeSpan)}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
title="Memory Working Set"
|
||||
data={formatValue(process.workingSet, formatBytes)}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
title="Memory Virtual Size"
|
||||
data={formatValue(process.virtualMemorySize, formatBytes)}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
title="Main Database Size"
|
||||
data={formatValue(databaseMain.size, formatBytes)}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
title="Logs Database Size"
|
||||
data={formatValue(databaseLog.size, formatBytes)}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
title="Commands Executed"
|
||||
data={formatValue(commandsExecuted, formatBytes)}
|
||||
/>
|
||||
</DescriptionList>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Statistics.propTypes = {
|
||||
process: PropTypes.object,
|
||||
databaseMain: PropTypes.object,
|
||||
databaseLog: PropTypes.object,
|
||||
commandsExecuted: PropTypes.number
|
||||
};
|
||||
|
||||
Statistics.defaultProps = {
|
||||
process: {},
|
||||
databaseMain: {},
|
||||
databaseLog: {}
|
||||
};
|
||||
|
||||
export default Statistics;
|
46
frontend/src/Diagnostic/Status/Status.js
Normal file
46
frontend/src/Diagnostic/Status/Status.js
Normal file
@ -0,0 +1,46 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import Statistics from './Statistics/Statistics';
|
||||
|
||||
class Status extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<PageContent title="Diagnostic Status">
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label="Refresh"
|
||||
iconName={icons.REFRESH}
|
||||
isSpinning={this.props.isStatusFetching}
|
||||
onPress={this.props.onRefreshPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
<PageContentBody>
|
||||
<Statistics
|
||||
{...this.props.status}
|
||||
/>
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Status.propTypes = {
|
||||
status: PropTypes.object.isRequired,
|
||||
isStatusFetching: PropTypes.bool.isRequired,
|
||||
onRefreshPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default Status;
|
59
frontend/src/Diagnostic/Status/StatusConnector.js
Normal file
59
frontend/src/Diagnostic/Status/StatusConnector.js
Normal file
@ -0,0 +1,59 @@
|
||||
// @ts-check
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchStatus } from 'Store/Actions/diagnosticActions';
|
||||
import Status from './Status';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.diagnostic.status,
|
||||
(status) => {
|
||||
return {
|
||||
isStatusFetching: status.isFetching,
|
||||
status: status.item
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchStatus
|
||||
};
|
||||
|
||||
class DiagnosticConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchStatus();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onRefreshPress = () => {
|
||||
this.props.fetchStatus();
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Status
|
||||
onRefreshPress={this.onRefreshPress}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DiagnosticConnector.propTypes = {
|
||||
status: PropTypes.object.isRequired,
|
||||
fetchStatus: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(DiagnosticConnector);
|
@ -81,6 +81,7 @@ import {
|
||||
faSignOutAlt as fasSignOutAlt,
|
||||
faSitemap as fasSitemap,
|
||||
faSpinner as fasSpinner,
|
||||
faStepForward as fasStepForward,
|
||||
faSort as fasSort,
|
||||
faSortDown as fasSortDown,
|
||||
faSortUp as fasSortUp,
|
||||
@ -126,6 +127,7 @@ export const CLONE = farClone;
|
||||
export const COLLAPSE = fasChevronCircleUp;
|
||||
export const COMPUTER = fasDesktop;
|
||||
export const DANGER = fasExclamationCircle;
|
||||
export const DEBUG = fasBug;
|
||||
export const DELETE = fasTrashAlt;
|
||||
export const DOWNLOAD = fasDownload;
|
||||
export const DOWNLOADED = fasDownload;
|
||||
@ -180,6 +182,8 @@ export const REORDER = fasBars;
|
||||
export const RSS = fasRss;
|
||||
export const SAVE = fasSave;
|
||||
export const SCHEDULED = farClock;
|
||||
export const SCRIPT_DEBUG = fasStepForward;
|
||||
export const SCRIPT_RUN = fasPlay;
|
||||
export const SCORE = fasUserPlus;
|
||||
export const SEARCH = fasSearch;
|
||||
export const SERIES_CONTINUING = fasPlay;
|
||||
|
185
frontend/src/Store/Actions/diagnosticActions.js
Normal file
185
frontend/src/Store/Actions/diagnosticActions.js
Normal file
@ -0,0 +1,185 @@
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import createFetchHandler from './Creators/createFetchHandler';
|
||||
import createHandleActions from './Creators/createHandleActions';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import { set } from './baseActions';
|
||||
|
||||
//
|
||||
// Variables
|
||||
|
||||
export const section = 'diagnostic';
|
||||
const scriptSection = 'diagnostic.script';
|
||||
|
||||
//
|
||||
// State
|
||||
|
||||
const exampleScript = `// Obtain the instance of ISeriesService
|
||||
var seriesService = Resolve<ISeriesService>();
|
||||
|
||||
// Get all series
|
||||
var series = seriesService.GetAllSeries();
|
||||
|
||||
// Find the top 5 highest rated ones
|
||||
var top5 = series.Where(s => s.Ratings.Votes > 6)
|
||||
.OrderByDescending(s => s.Ratings.Value)
|
||||
.Take(5)
|
||||
.Select(s => s.Title);
|
||||
|
||||
return new {
|
||||
Top5 = top5,
|
||||
Count = series.Count()
|
||||
};`;
|
||||
|
||||
export const defaultState = {
|
||||
status: {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
item: {}
|
||||
},
|
||||
|
||||
script: {
|
||||
isExecuting: false,
|
||||
isDebugging: false,
|
||||
isValidating: false,
|
||||
workspaceId: null,
|
||||
code: exampleScript,
|
||||
validation: null,
|
||||
result: null,
|
||||
error: null
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Actions Types
|
||||
|
||||
export const FETCH_STATUS = 'diagnostic/status/fetchStatus';
|
||||
export const UPDATE_SCRIPT = 'diagnostic/script/update';
|
||||
export const VALIDATE_SCRIPT = 'diagnostic/script/validate';
|
||||
export const EXECUTE_SCRIPT = 'diagnostic/script/execute';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
||||
export const fetchStatus = createThunk(FETCH_STATUS);
|
||||
export const updateScript = createThunk(UPDATE_SCRIPT);
|
||||
export const validateScript = createThunk(VALIDATE_SCRIPT);
|
||||
export const executeScript = createThunk(EXECUTE_SCRIPT);
|
||||
|
||||
//
|
||||
// Action Handlers
|
||||
|
||||
export const actionHandlers = handleThunks({
|
||||
[FETCH_STATUS]: createFetchHandler('diagnostic.status', '/diagnostic/status'),
|
||||
|
||||
[UPDATE_SCRIPT]: function(getState, payload, dispatch) {
|
||||
const {
|
||||
code
|
||||
} = payload;
|
||||
|
||||
dispatch(set({
|
||||
section: scriptSection,
|
||||
code
|
||||
}));
|
||||
},
|
||||
|
||||
[VALIDATE_SCRIPT]: function(getState, payload, dispatch) {
|
||||
const {
|
||||
code
|
||||
} = payload;
|
||||
|
||||
dispatch(set({
|
||||
section: scriptSection,
|
||||
code,
|
||||
isValidating: true
|
||||
}));
|
||||
|
||||
let ajaxOptions = null;
|
||||
|
||||
ajaxOptions = {
|
||||
url: '/diagnostic/script/validate',
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
dataType: 'json',
|
||||
data: JSON.stringify({
|
||||
code
|
||||
})
|
||||
};
|
||||
|
||||
const promise = createAjaxRequest(ajaxOptions).request;
|
||||
|
||||
promise.done((data) => {
|
||||
dispatch(set({
|
||||
section: scriptSection,
|
||||
isValidating: false,
|
||||
validation: data
|
||||
}));
|
||||
});
|
||||
|
||||
promise.fail((xhr) => {
|
||||
dispatch(set({
|
||||
section: scriptSection,
|
||||
isValidating: false,
|
||||
validation: null,
|
||||
error: xhr
|
||||
}));
|
||||
});
|
||||
},
|
||||
|
||||
[EXECUTE_SCRIPT]: function(getState, payload, dispatch) {
|
||||
const {
|
||||
code,
|
||||
debug
|
||||
} = payload;
|
||||
|
||||
dispatch(set({
|
||||
section: scriptSection,
|
||||
code,
|
||||
isExecuting: !debug,
|
||||
isDebugging: debug
|
||||
}));
|
||||
|
||||
let ajaxOptions = null;
|
||||
|
||||
ajaxOptions = {
|
||||
url: '/diagnostic/script/execute',
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
dataType: 'json',
|
||||
data: JSON.stringify({
|
||||
code,
|
||||
debug
|
||||
})
|
||||
};
|
||||
|
||||
const promise = createAjaxRequest(ajaxOptions).request;
|
||||
|
||||
promise.done((data) => {
|
||||
dispatch(set({
|
||||
section: scriptSection,
|
||||
isExecuting: false,
|
||||
isDebugging: false,
|
||||
result: (debug || data.error) ? data : data.returnValue,
|
||||
validation: {
|
||||
errorDiagnostics: data.errorDiagnostics
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
promise.fail((xhr) => {
|
||||
dispatch(set({
|
||||
section: scriptSection,
|
||||
isExecuting: false,
|
||||
isDebugging: false,
|
||||
error: xhr
|
||||
}));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
//
|
||||
// Reducers
|
||||
|
||||
export const reducers = createHandleActions({
|
||||
|
||||
}, defaultState, section);
|
@ -5,6 +5,7 @@ import * as calendar from './calendarActions';
|
||||
import * as captcha from './captchaActions';
|
||||
import * as customFilters from './customFilterActions';
|
||||
import * as commands from './commandActions';
|
||||
import * as diagnostic from './diagnosticActions';
|
||||
import * as episodes from './episodeActions';
|
||||
import * as episodeFiles from './episodeFileActions';
|
||||
import * as episodeHistory from './episodeHistoryActions';
|
||||
@ -36,6 +37,7 @@ export default [
|
||||
captcha,
|
||||
commands,
|
||||
customFilters,
|
||||
diagnostic,
|
||||
episodes,
|
||||
episodeFiles,
|
||||
episodeHistory,
|
||||
|
@ -77,6 +77,7 @@
|
||||
"mini-css-extract-plugin": "0.8.0",
|
||||
"mobile-detect": "1.4.3",
|
||||
"moment": "2.24.0",
|
||||
"monaco-editor": "0.20.0",
|
||||
"mousetrap": "1.6.3",
|
||||
"normalize.css": "8.0.1",
|
||||
"postcss-color-function": "4.1.0",
|
||||
@ -100,6 +101,7 @@
|
||||
"react-google-recaptcha": "1.1.0",
|
||||
"react-lazyload": "2.6.2",
|
||||
"react-measure": "1.4.7",
|
||||
"react-monaco-editor": "0.36.0",
|
||||
"react-popper": "1.3.3",
|
||||
"react-redux": "7.1.0",
|
||||
"react-router": "5.0.1",
|
||||
|
@ -0,0 +1,89 @@
|
||||
using System.Linq;
|
||||
using FizzWare.NBuilder;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Composition;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Core.Diagnostics;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
using NzbDrone.Core.Tv;
|
||||
using NzbDrone.Test.Common;
|
||||
|
||||
namespace NzbDrone.Core.Test.DiagnosticsTests
|
||||
{
|
||||
public class DiagnosticScriptRunnerFixture : CoreTest<DiagnosticScriptRunner>
|
||||
{
|
||||
[TestCase("1 ++ 2", "(1,6): error CS1002: ; expected")]
|
||||
[TestCase("a = 2", "(1,1): error CS0103: The name 'a' does not exist in the current context")]
|
||||
[TestCase("Logger.NoMethod()", "(1,8): error CS1061: 'Logger' does not contain a definition for 'NoMethod' and no accessible extension method 'NoMethod' accepting a first argument of type 'Logger' could be found (are you missing a using directive or an assembly reference?)")]
|
||||
public void Validate_should_list_compiler_errors(string source, string message)
|
||||
{
|
||||
var result = Subject.Validate(new ScriptRequest { Code = source, Debug = true });
|
||||
|
||||
result.HasErrors.Should().BeTrue();
|
||||
result.Messages.First().FullMessage.Should().Be("ScriptConsole.cs" + message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Execute_should_show_context()
|
||||
{
|
||||
var result = Subject.Execute(new ScriptRequest { Code = "var a = 12;" });
|
||||
|
||||
result.ReturnValue.Should().BeNull();
|
||||
result.Variables.Should().HaveCount(1);
|
||||
result.Variables["a"].Should().Be(12);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Execute_should_allow_continuations()
|
||||
{
|
||||
var result = Subject.Execute(new ScriptRequest { Code = "var a = 12;", StoreState = true });
|
||||
|
||||
var result2 = Subject.Execute(new ScriptRequest { Code = "var b = a + 2;", StateId = result.StateId });
|
||||
|
||||
result2.Variables.Should().HaveCount(2);
|
||||
result2.Variables["b"].Should().Be(14);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Execute_should_resolve_interfaces_Common()
|
||||
{
|
||||
Mocker.SetConstant<IContainer>(new AutoMoqerContainer(Mocker));
|
||||
|
||||
Mocker.GetMock<IDiskProvider>()
|
||||
.Setup(v => v.FolderExists("C:\test"))
|
||||
.Returns(true);
|
||||
|
||||
var result = Subject.Execute(new ScriptRequest { Code = @"
|
||||
var diskProvider = Resolve<IDiskProvider>();
|
||||
|
||||
return diskProvider.FolderExists(""C:\test"") ? ""yes"" : ""no"";
|
||||
" });
|
||||
|
||||
result.ReturnValue.Should().Be("yes");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Execute_should_resolve_interfaces_Core()
|
||||
{
|
||||
Mocker.SetConstant<IContainer>(new AutoMoqerContainer(Mocker));
|
||||
|
||||
Mocker.GetMock<ISeriesService>()
|
||||
.Setup(v => v.GetAllSeries())
|
||||
.Returns(Builder<Series>.CreateListOfSize(5).BuildList());
|
||||
|
||||
var result = Subject.Execute(new ScriptRequest { Code = @"
|
||||
var seriesService = Resolve<ISeriesService>();
|
||||
|
||||
foreach (var series in seriesService.GetAllSeries())
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
Logger.Debug($""Processing series {series.Title}"");
|
||||
}
|
||||
return ""done"";
|
||||
" });
|
||||
|
||||
result.ReturnValue.Should().Be("done");
|
||||
}
|
||||
}
|
||||
}
|
@ -9,6 +9,7 @@ public interface IDatabase
|
||||
{
|
||||
IDataMapper GetDataMapper();
|
||||
Version Version { get; }
|
||||
long Size { get; }
|
||||
void Vacuum();
|
||||
}
|
||||
|
||||
@ -39,6 +40,16 @@ public Version Version
|
||||
}
|
||||
}
|
||||
|
||||
public long Size
|
||||
{
|
||||
get
|
||||
{
|
||||
var page_count = _datamapperFactory().ExecuteScalar("PRAGMA page_count;");
|
||||
var page_size = _datamapperFactory().ExecuteScalar("PRAGMA page_size;");
|
||||
return Convert.ToInt64(page_count) * Convert.ToInt64(page_size);
|
||||
}
|
||||
}
|
||||
|
||||
public void Vacuum()
|
||||
{
|
||||
try
|
||||
|
@ -23,6 +23,8 @@ public IDataMapper GetDataMapper()
|
||||
}
|
||||
|
||||
public Version Version => _database.Version;
|
||||
public long Size => _database.Size;
|
||||
|
||||
|
||||
public void Vacuum()
|
||||
{
|
||||
|
@ -23,6 +23,7 @@ public IDataMapper GetDataMapper()
|
||||
}
|
||||
|
||||
public Version Version => _database.Version;
|
||||
public long Size => _database.Size;
|
||||
|
||||
public void Vacuum()
|
||||
{
|
||||
|
38
src/NzbDrone.Core/Diagnostics/DiagnosticFeatureSwitches.cs
Normal file
38
src/NzbDrone.Core/Diagnostics/DiagnosticFeatureSwitches.cs
Normal file
@ -0,0 +1,38 @@
|
||||
using System.IO;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
|
||||
namespace NzbDrone.Core.Diagnostics
|
||||
{
|
||||
public interface IDiagnosticFeatureSwitches
|
||||
{
|
||||
bool ScriptConsoleEnabled { get; }
|
||||
}
|
||||
|
||||
public class DiagnosticFeatureSwitches : IDiagnosticFeatureSwitches
|
||||
{
|
||||
private IDiskProvider _diskProvider;
|
||||
private IAppFolderInfo _appFolderInfo;
|
||||
|
||||
public DiagnosticFeatureSwitches(IDiskProvider diskProvider, IAppFolderInfo appFolderInfo)
|
||||
{
|
||||
_diskProvider = diskProvider;
|
||||
_appFolderInfo = appFolderInfo;
|
||||
}
|
||||
|
||||
public bool ScriptConsoleEnabled
|
||||
{
|
||||
get
|
||||
{
|
||||
// Only allow this if the 'debugscripts' config folder exists.
|
||||
// Scripting is a significant security risk with only an api key for protection.
|
||||
if (!_diskProvider.FolderExists(Path.Combine(_appFolderInfo.AppDataFolder, "debugscripts")))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
397
src/NzbDrone.Core/Diagnostics/DiagnosticScriptRunner.cs
Normal file
397
src/NzbDrone.Core/Diagnostics/DiagnosticScriptRunner.cs
Normal file
@ -0,0 +1,397 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Cache;
|
||||
using NzbDrone.Common.Composition;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
|
||||
namespace NzbDrone.Core.Diagnostics
|
||||
{
|
||||
public interface IDiagnosticScriptRunner
|
||||
{
|
||||
ScriptValidationResult Validate(ScriptRequest request);
|
||||
ScriptExecutionResult Execute(ScriptRequest request);
|
||||
}
|
||||
internal class CompilationContext
|
||||
{
|
||||
public HashSet<string> GlobalUsings { get; set; }
|
||||
public ScriptOptions Options { get; set; }
|
||||
public string Code { get; set; }
|
||||
public Script Script { get; set; }
|
||||
public Compilation LastCompilation { get; set; }
|
||||
}
|
||||
|
||||
public class DiagnosticScriptRunner : IDiagnosticScriptRunner
|
||||
{
|
||||
private static readonly Regex _regexResolve = new Regex(@"=\s+Resolve<(I\w+)>", RegexOptions.Compiled);
|
||||
private static readonly Assembly[] _assemblies = new[] {
|
||||
typeof(AppFolderInfo).Assembly,
|
||||
typeof(DiagnosticScriptRunner).Assembly
|
||||
};
|
||||
|
||||
private readonly IContainer _container;
|
||||
private readonly Logger _logger;
|
||||
|
||||
private readonly ICached<object> _scriptStateCache;
|
||||
|
||||
private WeakReference<CompilationContext> _lastCompilation;
|
||||
|
||||
public DiagnosticScriptRunner(IContainer container, ICacheManager cacheManager, Logger logger)
|
||||
{
|
||||
_container = container;
|
||||
_logger = logger;
|
||||
|
||||
// Note: using object instead of ScriptState to avoid the Scripting assembly to be loaded on startup.
|
||||
_scriptStateCache = cacheManager.GetCache<object>(GetType());
|
||||
|
||||
_lastCompilation = new WeakReference<CompilationContext>(null);
|
||||
|
||||
CheckScriptingAssemblyDelayLoad();
|
||||
}
|
||||
|
||||
private void CheckScriptingAssemblyDelayLoad()
|
||||
{
|
||||
var scriptingLoaded = AppDomain.CurrentDomain.GetAssemblies().Any(v => v.FullName.Contains("Microsoft.CodeAnalysis"));
|
||||
if (scriptingLoaded)
|
||||
{
|
||||
// If we reach this code, then the class has been changed and as a result the Microsoft.CodeAnalysis.CSharp.Scripting assembly
|
||||
// was loaded on startup. This should be avoided since it takes more memory and is not used in normal situations.
|
||||
if (!RuntimeInfo.IsProduction)
|
||||
{
|
||||
_logger.Error("Scripting assembly loaded prematurely.");
|
||||
}
|
||||
if (Debugger.IsAttached)
|
||||
{
|
||||
Debugger.Break();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ScriptValidationResult Validate(ScriptRequest request)
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
var globalUsings = GetGlobalUsings(request.Code);
|
||||
|
||||
_lastCompilation.TryGetTarget(out var lastCompilation);
|
||||
|
||||
// Swapping SyntaxTree is significantly faster and uses less memory
|
||||
if (lastCompilation != null && lastCompilation.Code == request.Code)
|
||||
{
|
||||
// Unchanged
|
||||
}
|
||||
else if (lastCompilation != null && lastCompilation.GlobalUsings == globalUsings)
|
||||
{
|
||||
var newSyntaxTree = CSharpSyntaxTree.ParseText(request.Code, CSharpParseOptions.Default.WithKind(SourceCodeKind.Script));
|
||||
|
||||
lastCompilation.Script = null;
|
||||
lastCompilation.Code = request.Code;
|
||||
lastCompilation.LastCompilation = lastCompilation.LastCompilation.ReplaceSyntaxTree(lastCompilation.LastCompilation.SyntaxTrees.First(), newSyntaxTree);
|
||||
}
|
||||
else
|
||||
{
|
||||
var options = GetOptions(globalUsings, request.Debug);
|
||||
|
||||
var script = CSharpScript.Create(request.Code, options, globalsType: typeof(ScriptContext));
|
||||
|
||||
var compilation = script.GetCompilation();
|
||||
|
||||
lastCompilation = new CompilationContext
|
||||
{
|
||||
GlobalUsings = globalUsings,
|
||||
Options = options,
|
||||
Code = request.Code,
|
||||
Script = script,
|
||||
LastCompilation = compilation
|
||||
};
|
||||
|
||||
_lastCompilation.SetTarget(lastCompilation);
|
||||
}
|
||||
|
||||
var diagnostics = lastCompilation.LastCompilation.GetDiagnostics();
|
||||
|
||||
return new ScriptValidationResult
|
||||
{
|
||||
Messages = diagnostics.Select(v => new ScriptDiagnostic(v)).ToArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public ScriptExecutionResult Execute(ScriptRequest request)
|
||||
{
|
||||
if (request.StateId != null)
|
||||
{
|
||||
return ExecuteAsync(request, request.StateId).GetAwaiter().GetResult();
|
||||
}
|
||||
else
|
||||
{
|
||||
return ExecuteAsync(request).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ScriptExecutionResult> ExecuteAsync(ScriptRequest request)
|
||||
{
|
||||
Script script;
|
||||
|
||||
lock (this)
|
||||
{
|
||||
var globalUsings = GetGlobalUsings(request.Code);
|
||||
|
||||
_lastCompilation.TryGetTarget(out var lastCompilation);
|
||||
|
||||
if (lastCompilation != null && lastCompilation.Code == request.Code && lastCompilation.Script != null &&
|
||||
lastCompilation.Options.EmitDebugInformation == request.Debug)
|
||||
{
|
||||
script = lastCompilation.Script;
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = GetOptions(globalUsings, request.Debug);
|
||||
|
||||
// Note: Using classic Task pipeline since async-await early loads the Scripts assembly
|
||||
script = CSharpScript.Create(request.Code, options, globalsType: typeof(ScriptContext));
|
||||
|
||||
var compilation = script.GetCompilation();
|
||||
|
||||
lastCompilation = new CompilationContext
|
||||
{
|
||||
GlobalUsings = globalUsings,
|
||||
Options = options,
|
||||
Code = request.Code,
|
||||
Script = script,
|
||||
LastCompilation = compilation
|
||||
};
|
||||
|
||||
_lastCompilation.SetTarget(lastCompilation);
|
||||
}
|
||||
catch (CompilationErrorException ex)
|
||||
{
|
||||
return Task.FromResult(GetResult(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return script.RunAsync(new ScriptContext(_container, _logger), ex => true).ContinueWith(t =>
|
||||
{
|
||||
var state = t.Result;
|
||||
|
||||
if (state.Exception != null)
|
||||
{
|
||||
return GetResult(state.Exception, request.Code);
|
||||
}
|
||||
else
|
||||
{
|
||||
return GetResult(state, request.StoreState);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (CompilationErrorException ex)
|
||||
{
|
||||
return Task.FromResult(GetResult(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ScriptExecutionResult> ExecuteAsync(ScriptRequest request, string stateId)
|
||||
{
|
||||
var options = GetOptions(GetGlobalUsings(request.Code), request.Debug);
|
||||
|
||||
var script = GetState(stateId);
|
||||
|
||||
try
|
||||
{
|
||||
// Note: Using classic Task pipeline since async-await early loads the Scripts assembly
|
||||
return script.ContinueWithAsync(request.Code, options, ex => true).ContinueWith(t =>
|
||||
{
|
||||
var state = t.Result;
|
||||
|
||||
if (state.Exception != null)
|
||||
{
|
||||
return GetResult(state.Exception, request.Code);
|
||||
}
|
||||
else
|
||||
{
|
||||
return GetResult(state, request.StoreState);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (CompilationErrorException ex)
|
||||
{
|
||||
return Task.FromResult(GetResult(ex));
|
||||
}
|
||||
}
|
||||
|
||||
private HashSet<string> GetGlobalUsings(string source)
|
||||
{
|
||||
var result = new HashSet<string>();
|
||||
|
||||
// Make the syntax easier by parsing Resolve<I..> and auto add using
|
||||
var matches = _regexResolve.Matches(source);
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
foreach (var ns in ResolveNamespaces(match.Groups[1].Value))
|
||||
{
|
||||
result.Add(ns);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private ScriptOptions GetOptions(HashSet<string> globalUsings, bool debug = false)
|
||||
{
|
||||
var options = ScriptOptions.Default
|
||||
.AddReferences(_assemblies)
|
||||
.AddImports(typeof(Task).Namespace)
|
||||
.AddImports(typeof(Enumerable).Namespace);
|
||||
|
||||
if (debug)
|
||||
{
|
||||
options = options.WithEmitDebugInformation(true)
|
||||
.WithFilePath("ScriptConsole.cs")
|
||||
.WithFileEncoding(Encoding.UTF8);
|
||||
}
|
||||
|
||||
// Make the syntax easier by parsing Resolve<I..> and auto add using
|
||||
foreach (var ns in globalUsings)
|
||||
{
|
||||
options = options.AddImports(ns);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private List<string> ResolveNamespaces(string type)
|
||||
{
|
||||
var types = _assemblies
|
||||
.SelectMany(v => v.GetExportedTypes())
|
||||
.Where(v => v.Name == type);
|
||||
|
||||
var namespaces = types.Select(v => v.Namespace)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return namespaces;
|
||||
}
|
||||
|
||||
private ScriptExecutionResult GetResult(ScriptState state, bool storeState)
|
||||
{
|
||||
var variables = state.Variables.Where(v => !v.Type.IsInterface || !v.Type.Namespace.StartsWith("NzbDrone")).ToDictionary(v => v.Name, v => v.Value);
|
||||
|
||||
var result = new ScriptExecutionResult
|
||||
{
|
||||
StateId = storeState ? StoreState(state) : null,
|
||||
ReturnValue = state.ReturnValue,
|
||||
Variables = variables.Any() ? variables : null
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private ScriptExecutionResult GetResult(CompilationErrorException ex)
|
||||
{
|
||||
return new ScriptExecutionResult
|
||||
{
|
||||
Exception = ex,
|
||||
Validation = new ScriptValidationResult { Messages = ex.Diagnostics.Select(v => new ScriptDiagnostic(v)).ToArray() }
|
||||
};
|
||||
}
|
||||
|
||||
private ScriptExecutionResult GetResult(Exception ex, string code)
|
||||
{
|
||||
var result = new ScriptExecutionResult
|
||||
{
|
||||
Exception = ex
|
||||
};
|
||||
|
||||
StackFrame firstScriptFrame = null;
|
||||
var stackTrace = new StackTrace(ex, true);
|
||||
|
||||
for (int i = 0; i < stackTrace.FrameCount; i++)
|
||||
{
|
||||
var frame = stackTrace.GetFrame(i);
|
||||
if (frame.GetFileName() == "ScriptConsole.cs" && result.Validation == null)
|
||||
{
|
||||
firstScriptFrame = frame;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the full message text till the scripting runtime
|
||||
var fullMessage = ex.ToString();
|
||||
var fullMessageLines = fullMessage.Split(new[] { Environment.NewLine }, StringSplitOptions.None);
|
||||
var idx = Array.FindIndex(fullMessageLines, 1, v => v.Contains("RunSubmissionsAsync")) - 2;
|
||||
if (idx > 2 && fullMessageLines[idx - 1].StartsWith("---"))
|
||||
{
|
||||
idx--;
|
||||
}
|
||||
|
||||
if (idx > 1)
|
||||
{
|
||||
fullMessage = string.Join("\n", fullMessageLines.Take(idx));
|
||||
}
|
||||
|
||||
var lines = code.Split('\n');
|
||||
ScriptDiagnostic diagnostic;
|
||||
if (firstScriptFrame != null)
|
||||
{
|
||||
var startLineNumber = firstScriptFrame.GetFileLineNumber();
|
||||
var startColumn = firstScriptFrame.GetFileColumnNumber();
|
||||
var endLineNumber = startLineNumber;
|
||||
var endColumn = startColumn == 1 ? lines[startLineNumber - 1].Length : startColumn;
|
||||
diagnostic = new ScriptDiagnostic(ex, startLineNumber, startColumn, endLineNumber, endColumn, fullMessage);
|
||||
}
|
||||
else
|
||||
{
|
||||
diagnostic = new ScriptDiagnostic(ex, 1, 1, lines.Length, lines.Last().Length, fullMessage);
|
||||
}
|
||||
|
||||
result.Validation = new ScriptValidationResult
|
||||
{
|
||||
Messages = new[]
|
||||
{
|
||||
diagnostic
|
||||
}
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private ScriptState GetState(string stateID)
|
||||
{
|
||||
var state = _scriptStateCache.Find(stateID);
|
||||
|
||||
if (state == null)
|
||||
throw new KeyNotFoundException($"ScriptState {stateID} no longer exists");
|
||||
|
||||
return state as ScriptState;
|
||||
}
|
||||
|
||||
private void RemoveState(string stateID)
|
||||
{
|
||||
_scriptStateCache.Remove(stateID);
|
||||
}
|
||||
|
||||
private string StoreState(ScriptState state)
|
||||
{
|
||||
var key = Guid.NewGuid().ToString();
|
||||
|
||||
_scriptStateCache.Set(key, state, TimeSpan.FromHours(1));
|
||||
|
||||
return key;
|
||||
}
|
||||
}
|
||||
}
|
25
src/NzbDrone.Core/Diagnostics/ScriptContext.cs
Normal file
25
src/NzbDrone.Core/Diagnostics/ScriptContext.cs
Normal file
@ -0,0 +1,25 @@
|
||||
using NLog;
|
||||
using NzbDrone.Common.Composition;
|
||||
|
||||
namespace NzbDrone.Core.Diagnostics
|
||||
{
|
||||
public class ScriptContext
|
||||
{
|
||||
private readonly IContainer _container;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public ScriptContext(IContainer container, Logger logger)
|
||||
{
|
||||
_container = container;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Logger Logger => _logger;
|
||||
|
||||
public T Resolve<T>()
|
||||
where T : class
|
||||
{
|
||||
return _container.Resolve<T>();
|
||||
}
|
||||
}
|
||||
}
|
52
src/NzbDrone.Core/Diagnostics/ScriptDiagnostic.cs
Normal file
52
src/NzbDrone.Core/Diagnostics/ScriptDiagnostic.cs
Normal file
@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace NzbDrone.Core.Diagnostics
|
||||
{
|
||||
public enum ScriptDiagnosticSeverity
|
||||
{
|
||||
Info = 1,
|
||||
Warning = 2,
|
||||
Error = 3
|
||||
}
|
||||
|
||||
public class ScriptDiagnostic
|
||||
{
|
||||
public int StartLineNumber { get; set; }
|
||||
public int StartColumn { get; set; }
|
||||
public int EndLineNumber { get; set; }
|
||||
public int EndColumn { get; set; }
|
||||
public string Message { get; set; }
|
||||
public ScriptDiagnosticSeverity Severity { get; set; }
|
||||
public string FullMessage { get; set; }
|
||||
|
||||
public ScriptDiagnostic()
|
||||
{
|
||||
}
|
||||
|
||||
public ScriptDiagnostic(Exception ex, int startLineNumber, int startColumn, int endLineNumber, int endColumn, string fullMessage)
|
||||
{
|
||||
StartLineNumber = startLineNumber;
|
||||
StartColumn = startColumn;
|
||||
EndLineNumber = endLineNumber;
|
||||
EndColumn = endColumn;
|
||||
Message = ex.Message;
|
||||
Severity = ScriptDiagnosticSeverity.Error;
|
||||
FullMessage = fullMessage;
|
||||
}
|
||||
|
||||
public ScriptDiagnostic(Diagnostic diagnostic)
|
||||
{
|
||||
var lineSpan = diagnostic.Location.GetLineSpan();
|
||||
|
||||
StartLineNumber = lineSpan.StartLinePosition.Line + 1;
|
||||
StartColumn = lineSpan.StartLinePosition.Character + 1;
|
||||
EndLineNumber = lineSpan.EndLinePosition.Line + 1;
|
||||
EndColumn = lineSpan.EndLinePosition.Character + 1;
|
||||
Message = diagnostic.GetMessage();
|
||||
Severity = (ScriptDiagnosticSeverity)diagnostic.Severity;
|
||||
FullMessage = diagnostic.ToString();
|
||||
}
|
||||
}
|
||||
}
|
15
src/NzbDrone.Core/Diagnostics/ScriptExecutionResult.cs
Normal file
15
src/NzbDrone.Core/Diagnostics/ScriptExecutionResult.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace NzbDrone.Core.Diagnostics
|
||||
{
|
||||
public class ScriptExecutionResult
|
||||
{
|
||||
public string StateId { get; set; }
|
||||
|
||||
public Exception Exception { get; set; }
|
||||
public object ReturnValue { get; set; }
|
||||
public Dictionary<string, object> Variables { get; set; }
|
||||
public ScriptValidationResult Validation { get; set; }
|
||||
}
|
||||
}
|
11
src/NzbDrone.Core/Diagnostics/ScriptRequest.cs
Normal file
11
src/NzbDrone.Core/Diagnostics/ScriptRequest.cs
Normal file
@ -0,0 +1,11 @@
|
||||
namespace NzbDrone.Core.Diagnostics
|
||||
{
|
||||
public class ScriptRequest
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Code { get; set; }
|
||||
public string StateId { get; set; }
|
||||
public bool Debug { get; set; }
|
||||
public bool StoreState { get; set; }
|
||||
}
|
||||
}
|
14
src/NzbDrone.Core/Diagnostics/ScriptValidationResult.cs
Normal file
14
src/NzbDrone.Core/Diagnostics/ScriptValidationResult.cs
Normal file
@ -0,0 +1,14 @@
|
||||
using System.Linq;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace NzbDrone.Core.Diagnostics
|
||||
{
|
||||
public class ScriptValidationResult
|
||||
{
|
||||
public ScriptDiagnostic[] Messages { get; set; }
|
||||
|
||||
public bool HasWarnings => Messages.Any(v => v.Severity == ScriptDiagnosticSeverity.Warning);
|
||||
public bool HasErrors => Messages.Any(v => v.Severity == ScriptDiagnosticSeverity.Error);
|
||||
|
||||
}
|
||||
}
|
@ -8,6 +8,7 @@
|
||||
<PackageReference Include="FluentValidation" Version="8.4.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="1.0.0-beta0007" />
|
||||
<PackageReference Include="System.Numerics.Vectors" Version="4.5.0" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="3.6.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
|
||||
<PackageReference Include="NLog" Version="4.6.6" />
|
||||
<PackageReference Include="OAuth" Version="1.0.3" />
|
||||
|
@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.Diagnostics;
|
||||
using NzbDrone.Integration.Test.Client;
|
||||
using RestSharp;
|
||||
using Sonarr.Api.V3.Diagnostics;
|
||||
|
||||
namespace NzbDrone.Integration.Test.DiagnosticsTests
|
||||
{
|
||||
public class DiagnosticScriptResource
|
||||
{
|
||||
public string Code { get; set; }
|
||||
public bool? Debug { get; set; }
|
||||
|
||||
public object ReturnValue { get; set; }
|
||||
public Dictionary<string, object> DebugVariables { get; set; }
|
||||
|
||||
public string Error { get; set; }
|
||||
public List<ScriptDiagnostic> ErrorDiagnostics { get; set; }
|
||||
}
|
||||
|
||||
public class DiagnosticsScriptClient : ClientBase
|
||||
{
|
||||
public DiagnosticsScriptClient(IRestClient restClient, string apiKey)
|
||||
: base(restClient, apiKey, "v3/diagnostic/script")
|
||||
{
|
||||
}
|
||||
|
||||
public DiagnosticScriptResource Execute(DiagnosticScriptResource body, HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||
{
|
||||
var request = BuildRequest("execute");
|
||||
request.Method = Method.POST;
|
||||
request.AddJsonBody(body);
|
||||
return Execute<DiagnosticScriptResource>(request, statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
public class DiagnosticsScriptModuleFixture : IntegrationTest
|
||||
{
|
||||
DiagnosticsScriptClient DiagScript { get; set; }
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
DiagScript = new DiagnosticsScriptClient(RestClient, ApiKey);
|
||||
}
|
||||
|
||||
private void GivenEnabledFeature(bool enabled = true)
|
||||
{
|
||||
var debugscripts = Path.Combine(_runner.AppData, "debugscripts");
|
||||
|
||||
if (enabled && !Directory.Exists(debugscripts))
|
||||
{
|
||||
Directory.CreateDirectory(debugscripts);
|
||||
}
|
||||
else if (!enabled && Directory.Exists(debugscripts))
|
||||
{
|
||||
Directory.Delete(debugscripts);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_allow_access_without_debugscripts_dir()
|
||||
{
|
||||
GivenEnabledFeature(false);
|
||||
|
||||
DiagScript.Execute(new DiagnosticScriptResource
|
||||
{
|
||||
Code = "return \"abc\";"
|
||||
}, HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_allow_access_with_debugscripts_dir()
|
||||
{
|
||||
GivenEnabledFeature(true);
|
||||
|
||||
var result = DiagScript.Execute(new DiagnosticScriptResource
|
||||
{
|
||||
Code = "return \"abc\";"
|
||||
});
|
||||
|
||||
result.ReturnValue.Should().Be("abc");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_include_variables_for_debug()
|
||||
{
|
||||
GivenEnabledFeature();
|
||||
|
||||
var result = DiagScript.Execute(new DiagnosticScriptResource
|
||||
{
|
||||
Code = "var a = \"abc\";",
|
||||
Debug = true
|
||||
});
|
||||
|
||||
result.ReturnValue.Should().BeNull();
|
||||
result.DebugVariables.Should().Contain("a", "abc");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_include_variables_without_debug()
|
||||
{
|
||||
GivenEnabledFeature();
|
||||
|
||||
var result = DiagScript.Execute(new DiagnosticScriptResource
|
||||
{
|
||||
Code = "var a = \"abc\";"
|
||||
});
|
||||
|
||||
result.ReturnValue.Should().BeNull();
|
||||
result.DebugVariables.Should().BeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_report_compile_errors_with_debug()
|
||||
{
|
||||
GivenEnabledFeature();
|
||||
|
||||
var result = DiagScript.Execute(new DiagnosticScriptResource
|
||||
{
|
||||
Code = "var a = \"abc\" + b;",
|
||||
Debug = true
|
||||
});
|
||||
|
||||
result.ReturnValue.Should().BeNull();
|
||||
result.DebugVariables.Should().BeNull();
|
||||
result.Error.Should().Be("ScriptConsole.cs(1,17): error CS0103: The name 'b' does not exist in the current context");
|
||||
result.ErrorDiagnostics.Should().NotBeNull();
|
||||
result.ErrorDiagnostics.First().Message.Should().Be("The name 'b' does not exist in the current context");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_report_compile_errors_without_debug()
|
||||
{
|
||||
GivenEnabledFeature();
|
||||
|
||||
var result = DiagScript.Execute(new DiagnosticScriptResource
|
||||
{
|
||||
Code = "var a = \"abc\" + b;"
|
||||
});
|
||||
|
||||
result.ReturnValue.Should().BeNull();
|
||||
result.DebugVariables.Should().BeNull();
|
||||
result.Error.Should().Be("(1,17): error CS0103: The name 'b' does not exist in the current context");
|
||||
result.ErrorDiagnostics.Should().NotBeNull();
|
||||
result.ErrorDiagnostics.First().Message.Should().Be("The name 'b' does not exist in the current context");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_report_execution_errors_with_debug()
|
||||
{
|
||||
GivenEnabledFeature();
|
||||
|
||||
var result = DiagScript.Execute(new DiagnosticScriptResource
|
||||
{
|
||||
Code = "var seriesService = Resolve<ISeriesService>();\nseriesService.AddSeries((Series)null);",
|
||||
Debug = true
|
||||
});
|
||||
|
||||
result.ReturnValue.Should().BeNull();
|
||||
result.DebugVariables.Should().BeNull();
|
||||
result.Error.Should().StartWith("Object reference not set to an instance of an object");
|
||||
result.ErrorDiagnostics.Should().NotBeNull();
|
||||
result.ErrorDiagnostics.First().StartLineNumber.Should().Be(2);
|
||||
result.ErrorDiagnostics.First().StartColumn.Should().Be(1);
|
||||
result.ErrorDiagnostics.First().Message.Should().StartWith("Object reference not set to an instance of an object");
|
||||
result.ErrorDiagnostics.First().FullMessage.Should().StartWith("System.NullReferenceException: Object reference not set to an instance of an object");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_report_execution_errors_without_debug()
|
||||
{
|
||||
GivenEnabledFeature();
|
||||
|
||||
var result = DiagScript.Execute(new DiagnosticScriptResource
|
||||
{
|
||||
Code = "var seriesService = Resolve<ISeriesService>();\nseriesService.AddSeries((Series)null);",
|
||||
});
|
||||
|
||||
result.ReturnValue.Should().BeNull();
|
||||
result.DebugVariables.Should().BeNull();
|
||||
result.Error.Should().StartWith("Object reference not set to an instance of an object");
|
||||
result.ErrorDiagnostics.Should().NotBeNull();
|
||||
result.ErrorDiagnostics.First().StartLineNumber.Should().Be(1);
|
||||
result.ErrorDiagnostics.First().EndLineNumber.Should().Be(2);
|
||||
result.ErrorDiagnostics.First().EndColumn.Should().Be(38);
|
||||
result.ErrorDiagnostics.First().Message.Should().StartWith("Object reference not set to an instance of an object");
|
||||
result.ErrorDiagnostics.First().FullMessage.Should().StartWith("System.NullReferenceException: Object reference not set to an instance of an object");
|
||||
}
|
||||
}
|
||||
}
|
@ -9,5 +9,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NzbDrone.Api\Sonarr.Api.csproj" />
|
||||
<ProjectReference Include="..\NzbDrone.Test.Common\Sonarr.Test.Common.csproj" />
|
||||
<ProjectReference Include="..\Sonarr.Api.V3\Sonarr.Api.V3.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
71
src/NzbDrone.Test.Common/AutoMoqerContainer.cs
Normal file
71
src/NzbDrone.Test.Common/AutoMoqerContainer.cs
Normal file
@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NzbDrone.Common.Composition;
|
||||
|
||||
namespace NzbDrone.Test.Common
|
||||
{
|
||||
public class AutoMoqerContainer : IContainer
|
||||
{
|
||||
private readonly AutoMoq.AutoMoqer _autoMoqer;
|
||||
|
||||
public AutoMoqerContainer(AutoMoq.AutoMoqer autoMoqer)
|
||||
{
|
||||
_autoMoqer = autoMoqer;
|
||||
}
|
||||
|
||||
public IEnumerable<Type> GetImplementations(Type contractType)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public bool IsTypeRegistered(Type type)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public void Register<T>(T instance) where T : class
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public void Register(Type serviceType, Type implementationType)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public void Register<TService>(Func<IContainer, TService> factory) where TService : class
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public void RegisterAllAsSingleton(Type registrationType, IEnumerable<Type> implementationList)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public void RegisterSingleton(Type service, Type implementation)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public T Resolve<T>() where T : class
|
||||
{
|
||||
return _autoMoqer.Resolve<T>();
|
||||
}
|
||||
|
||||
public object Resolve(Type type)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public IEnumerable<T> ResolveAll<T>() where T : class
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
void IContainer.Register<TService, TImplementation>()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
63
src/Sonarr.Api.V3/Diagnostics/DiagnosticStatusModule.cs
Normal file
63
src/Sonarr.Api.V3/Diagnostics/DiagnosticStatusModule.cs
Normal file
@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using Nancy;
|
||||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.Diagnostics;
|
||||
using Sonarr.Api.V3;
|
||||
using Sonarr.Http.Extensions;
|
||||
|
||||
namespace NzbDrone.Api.V3.Diagnostics
|
||||
{
|
||||
public class DiagnosticStatusModule : SonarrV3Module
|
||||
{
|
||||
private readonly IDiagnosticFeatureSwitches _featureSwitches;
|
||||
private readonly IMainDatabase _mainDatabase;
|
||||
private readonly ILogDatabase _logDatabase;
|
||||
|
||||
public DiagnosticStatusModule(IDiagnosticFeatureSwitches featureSwitches,
|
||||
IMainDatabase mainDatabase,
|
||||
ILogDatabase logDatabase)
|
||||
: base("diagnostic")
|
||||
{
|
||||
_featureSwitches = featureSwitches;
|
||||
_mainDatabase = mainDatabase;
|
||||
_logDatabase = logDatabase;
|
||||
|
||||
Get("/status", x => GetStatus());
|
||||
}
|
||||
|
||||
private object GetStatus()
|
||||
{
|
||||
return new
|
||||
{
|
||||
Process = GetProcessStats(),
|
||||
DatabaseMain = GetDatabaseStats(_mainDatabase),
|
||||
DatabaseLog = GetDatabaseStats(_logDatabase),
|
||||
CommandsExecuted = (long?)null,
|
||||
ScriptConsoleEnabled = _featureSwitches.ScriptConsoleEnabled
|
||||
};
|
||||
}
|
||||
|
||||
private object GetProcessStats()
|
||||
{
|
||||
var process = Process.GetCurrentProcess();
|
||||
|
||||
return new
|
||||
{
|
||||
StartTime = process.StartTime,
|
||||
TotalProcessorTime = process.TotalProcessorTime.TotalMilliseconds,
|
||||
WorkingSet = process.WorkingSet64,
|
||||
VirtualMemorySize = process.VirtualMemorySize64,
|
||||
};
|
||||
}
|
||||
|
||||
private object GetDatabaseStats(IDatabase database)
|
||||
{
|
||||
return new
|
||||
{
|
||||
Size = database.Size,
|
||||
Version = database.Version
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
80
src/Sonarr.Api.V3/Diagnostics/ScriptRunnerModule.cs
Normal file
80
src/Sonarr.Api.V3/Diagnostics/ScriptRunnerModule.cs
Normal file
@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Nancy;
|
||||
using Nancy.Extensions;
|
||||
using NzbDrone.Core.Diagnostics;
|
||||
using Sonarr.Http.Extensions;
|
||||
|
||||
namespace Sonarr.Api.V3.Diagnostics
|
||||
{
|
||||
public class ScriptRunnerModule : SonarrV3Module
|
||||
{
|
||||
private readonly IDiagnosticScriptRunner _scriptRunner;
|
||||
private readonly IDiagnosticFeatureSwitches _featureSwitches;
|
||||
|
||||
public ScriptRunnerModule(IDiagnosticScriptRunner scriptRunner,
|
||||
IDiagnosticFeatureSwitches featureSwitches)
|
||||
: base("diagnostic/script")
|
||||
{
|
||||
_scriptRunner = scriptRunner;
|
||||
_featureSwitches = featureSwitches;
|
||||
|
||||
Post("/validate", x => ValidateScript(x));
|
||||
Post("/execute", x => ExecuteScript(x));
|
||||
}
|
||||
|
||||
private ScriptRequest ParseRequest()
|
||||
{
|
||||
if (Request.Headers.ContentType == "application/json")
|
||||
{
|
||||
return Request.Body.FromJson<ScriptRequest>();
|
||||
}
|
||||
else if (Request.Headers.ContentType == "text/plain")
|
||||
{
|
||||
return new ScriptRequest { Code = Context.Request.Body.AsString() };
|
||||
}
|
||||
else
|
||||
{
|
||||
return Request.Body.FromJson<ScriptRequest>();
|
||||
}
|
||||
}
|
||||
|
||||
public object ValidateScript(dynamic options)
|
||||
{
|
||||
if (!_featureSwitches.ScriptConsoleEnabled)
|
||||
{
|
||||
return new NotFoundResponse();
|
||||
}
|
||||
|
||||
var request = ParseRequest();
|
||||
|
||||
var result = _scriptRunner.Validate(request);
|
||||
|
||||
return new
|
||||
{
|
||||
ErrorDiagnostics = result.Messages?.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
public object ExecuteScript(dynamic options)
|
||||
{
|
||||
if (!_featureSwitches.ScriptConsoleEnabled)
|
||||
{
|
||||
return new NotFoundResponse();
|
||||
}
|
||||
|
||||
var request = ParseRequest();
|
||||
|
||||
var result = _scriptRunner.Execute(request);
|
||||
|
||||
return new
|
||||
{
|
||||
ResultStateId = result.StateId,
|
||||
ReturnValue = result.ReturnValue,
|
||||
DebugVariables = request.Debug ? result.Variables : null,
|
||||
Error = result.Exception?.Message,
|
||||
ErrorDiagnostics = result.Validation?.Messages?.ToArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
22
yarn.lock
22
yarn.lock
@ -1080,6 +1080,14 @@
|
||||
"@types/prop-types" "*"
|
||||
csstype "^2.2.0"
|
||||
|
||||
"@types/react@^16.x":
|
||||
version "16.9.35"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.35.tgz#a0830d172e8aadd9bd41709ba2281a3124bbd368"
|
||||
integrity sha512-q0n0SsWcGc8nDqH2GJfWQWUOmZSJhXV64CjVN5SvcNti3TdEaA3AH0D8DwNmMdzjMAC/78tB8nAZIlV8yTz+zQ==
|
||||
dependencies:
|
||||
"@types/prop-types" "*"
|
||||
csstype "^2.2.0"
|
||||
|
||||
"@types/shallowequal@^1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/shallowequal/-/shallowequal-1.1.1.tgz#aad262bb3f2b1257d94c71d545268d592575c9b1"
|
||||
@ -5922,6 +5930,11 @@ moment@2.24.0:
|
||||
resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
|
||||
integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
|
||||
|
||||
monaco-editor@*, monaco-editor@0.20.0:
|
||||
version "0.20.0"
|
||||
resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.20.0.tgz#5d5009343a550124426cb4d965a4d27a348b4dea"
|
||||
integrity sha512-hkvf4EtPJRMQlPC3UbMoRs0vTAFAYdzFQ+gpMb8A+9znae1c43q8Mab9iVsgTcg/4PNiLGGn3SlDIa8uvK1FIQ==
|
||||
|
||||
mousetrap@1.6.3:
|
||||
version "1.6.3"
|
||||
resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.3.tgz#80fee49665fd478bccf072c9d46bdf1bfed3558a"
|
||||
@ -7381,6 +7394,15 @@ react-measure@1.4.7:
|
||||
prop-types "^15.5.4"
|
||||
resize-observer-polyfill "^1.4.1"
|
||||
|
||||
react-monaco-editor@0.36.0:
|
||||
version "0.36.0"
|
||||
resolved "https://registry.yarnpkg.com/react-monaco-editor/-/react-monaco-editor-0.36.0.tgz#ac085c14f25fb072514c925596f6a06a711ee078"
|
||||
integrity sha512-JVA5SZhOoYZ0DCdTwYgagtRb3jHo4KN7TVFiJauG+ZBAJWfDSTzavPIrwzWbgu8ahhDqDk4jUcYlOJL2BC/0UA==
|
||||
dependencies:
|
||||
"@types/react" "^16.x"
|
||||
monaco-editor "*"
|
||||
prop-types "^15.7.2"
|
||||
|
||||
react-popper@1.3.3:
|
||||
version "1.3.3"
|
||||
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-1.3.3.tgz#2c6cef7515a991256b4f0536cd4bdcb58a7b6af6"
|
||||
|
Loading…
Reference in New Issue
Block a user