1
0
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:
Taloth Saldono 2019-08-19 20:37:27 +02:00
parent 031371652b
commit 94f8e38d5a
36 changed files with 1890 additions and 1 deletions

View File

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

View File

@ -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: {

View File

@ -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
*/}

View File

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

View File

@ -41,6 +41,7 @@ function PageToolbarButton(props) {
}
PageToolbarButton.propTypes = {
...Link.propTypes,
label: PropTypes.string.isRequired,
iconName: PropTypes.object.isRequired,
spinningName: PropTypes.object,

View 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;

View 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;

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

View File

@ -0,0 +1,6 @@
.split {
display: flex;
justify-content: space-between;
overflow: hidden;
height: 100%;
}

View 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;

View File

@ -0,0 +1,5 @@
.descriptionList {
composes: descriptionList from '~Components/DescriptionList/DescriptionList.css';
margin-bottom: 10px;
}

View 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;

View 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;

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

View File

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

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

View File

@ -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,

View File

@ -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",

View File

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

View File

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

View File

@ -23,6 +23,8 @@ public IDataMapper GetDataMapper()
}
public Version Version => _database.Version;
public long Size => _database.Size;
public void Vacuum()
{

View File

@ -23,6 +23,7 @@ public IDataMapper GetDataMapper()
}
public Version Version => _database.Version;
public long Size => _database.Size;
public void Vacuum()
{

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

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

View 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>();
}
}
}

View 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();
}
}
}

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

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

View 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);
}
}

View File

@ -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" />

View File

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

View File

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

View 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();
}
}
}

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

View 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()
};
}
}
}

View File

@ -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"