diff --git a/distribution/debian/rules b/distribution/debian/rules index e775b54f7..c63fc0a1a 100644 --- a/distribution/debian/rules +++ b/distribution/debian/rules @@ -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 diff --git a/frontend/gulp/webpack.js b/frontend/gulp/webpack.js index c6c5bc2d9..c5d6202bb 100644 --- a/frontend/gulp/webpack.js +++ b/frontend/gulp/webpack.js @@ -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: { diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index 4ab75eb22..8930a7447 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -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 + */} + + + {/* Not Found */} diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js index 6ffdf53cc..ca614aad9 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebar.js +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -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 ( + + + + {/* Redirect root to status */} + { + return ( + + ); + }} + /> + + ); + } +} + +export default Diagnostic; diff --git a/frontend/src/Diagnostic/Script/MonacoEditor.js b/frontend/src/Diagnostic/Script/MonacoEditor.js new file mode 100644 index 000000000..c8856cf34 --- /dev/null +++ b/frontend/src/Diagnostic/Script/MonacoEditor.js @@ -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; diff --git a/frontend/src/Diagnostic/Script/ScriptConnector.js b/frontend/src/Diagnostic/Script/ScriptConnector.js new file mode 100644 index 000000000..69e5626ab --- /dev/null +++ b/frontend/src/Diagnostic/Script/ScriptConnector.js @@ -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 ( + + + + ); + } else if (!this.props.isScriptConsoleEnabled) { + return ( + + + Diagnostic Scripting is disabled + + + ); + } + + return ( + + ); + } +} + +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); diff --git a/frontend/src/Diagnostic/Script/ScriptConsole.css b/frontend/src/Diagnostic/Script/ScriptConsole.css new file mode 100644 index 000000000..133214caa --- /dev/null +++ b/frontend/src/Diagnostic/Script/ScriptConsole.css @@ -0,0 +1,6 @@ +.split { + display: flex; + justify-content: space-between; + overflow: hidden; + height: 100%; +} diff --git a/frontend/src/Diagnostic/Script/ScriptConsole.js b/frontend/src/Diagnostic/Script/ScriptConsole.js new file mode 100644 index 000000000..cf22c4b5d --- /dev/null +++ b/frontend/src/Diagnostic/Script/ScriptConsole.js @@ -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 ( + + + + + + + + }> +
+ + +
+
+
+ ); + } +} + +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; diff --git a/frontend/src/Diagnostic/Status/Statistics/Statistics.css b/frontend/src/Diagnostic/Status/Statistics/Statistics.css new file mode 100644 index 000000000..9886c7ad0 --- /dev/null +++ b/frontend/src/Diagnostic/Status/Statistics/Statistics.css @@ -0,0 +1,5 @@ +.descriptionList { + composes: descriptionList from '~Components/DescriptionList/DescriptionList.css'; + + margin-bottom: 10px; +} diff --git a/frontend/src/Diagnostic/Status/Statistics/Statistics.js b/frontend/src/Diagnostic/Status/Statistics/Statistics.js new file mode 100644 index 000000000..af0335abb --- /dev/null +++ b/frontend/src/Diagnostic/Status/Statistics/Statistics.js @@ -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 ( +
+ + formatTimeSpan(moment().diff(startTime)))} + /> + + + + + + + + + + + + + +
+ ); + } + +} + +Statistics.propTypes = { + process: PropTypes.object, + databaseMain: PropTypes.object, + databaseLog: PropTypes.object, + commandsExecuted: PropTypes.number +}; + +Statistics.defaultProps = { + process: {}, + databaseMain: {}, + databaseLog: {} +}; + +export default Statistics; diff --git a/frontend/src/Diagnostic/Status/Status.js b/frontend/src/Diagnostic/Status/Status.js new file mode 100644 index 000000000..1b9edeb12 --- /dev/null +++ b/frontend/src/Diagnostic/Status/Status.js @@ -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 ( + + + + + + + + + + + ); + } + +} + +Status.propTypes = { + status: PropTypes.object.isRequired, + isStatusFetching: PropTypes.bool.isRequired, + onRefreshPress: PropTypes.func.isRequired +}; + +export default Status; diff --git a/frontend/src/Diagnostic/Status/StatusConnector.js b/frontend/src/Diagnostic/Status/StatusConnector.js new file mode 100644 index 000000000..45c3769a0 --- /dev/null +++ b/frontend/src/Diagnostic/Status/StatusConnector.js @@ -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 ( + + ); + } +} + +DiagnosticConnector.propTypes = { + status: PropTypes.object.isRequired, + fetchStatus: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DiagnosticConnector); diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index cad3ef748..ec06b2f16 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -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; diff --git a/frontend/src/Store/Actions/diagnosticActions.js b/frontend/src/Store/Actions/diagnosticActions.js new file mode 100644 index 000000000..cd7e8eef9 --- /dev/null +++ b/frontend/src/Store/Actions/diagnosticActions.js @@ -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(); + +// 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); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 77e2a1efa..320bd7fee 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -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, diff --git a/package.json b/package.json index 6e77a0d6e..758fb8c51 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/NzbDrone.Core.Test/DiagnosticsTests/DiagnosticScriptRunnerFixture.cs b/src/NzbDrone.Core.Test/DiagnosticsTests/DiagnosticScriptRunnerFixture.cs new file mode 100644 index 000000000..084059eae --- /dev/null +++ b/src/NzbDrone.Core.Test/DiagnosticsTests/DiagnosticScriptRunnerFixture.cs @@ -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 + { + [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(new AutoMoqerContainer(Mocker)); + + Mocker.GetMock() + .Setup(v => v.FolderExists("C:\test")) + .Returns(true); + + var result = Subject.Execute(new ScriptRequest { Code = @" + var diskProvider = Resolve(); + + return diskProvider.FolderExists(""C:\test"") ? ""yes"" : ""no""; + " }); + + result.ReturnValue.Should().Be("yes"); + } + + [Test] + public void Execute_should_resolve_interfaces_Core() + { + Mocker.SetConstant(new AutoMoqerContainer(Mocker)); + + Mocker.GetMock() + .Setup(v => v.GetAllSeries()) + .Returns(Builder.CreateListOfSize(5).BuildList()); + + var result = Subject.Execute(new ScriptRequest { Code = @" + var seriesService = Resolve(); + + foreach (var series in seriesService.GetAllSeries()) + { + await Task.Delay(1000); + Logger.Debug($""Processing series {series.Title}""); + } + return ""done""; + " }); + + result.ReturnValue.Should().Be("done"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Database.cs b/src/NzbDrone.Core/Datastore/Database.cs index 991cd9b0e..eda3897aa 100644 --- a/src/NzbDrone.Core/Datastore/Database.cs +++ b/src/NzbDrone.Core/Datastore/Database.cs @@ -9,6 +9,7 @@ namespace NzbDrone.Core.Datastore { IDataMapper GetDataMapper(); Version Version { get; } + long Size { get; } void Vacuum(); } @@ -39,6 +40,16 @@ namespace NzbDrone.Core.Datastore } } + 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 diff --git a/src/NzbDrone.Core/Datastore/LogDatabase.cs b/src/NzbDrone.Core/Datastore/LogDatabase.cs index c454e9997..6d91d0229 100644 --- a/src/NzbDrone.Core/Datastore/LogDatabase.cs +++ b/src/NzbDrone.Core/Datastore/LogDatabase.cs @@ -23,6 +23,8 @@ namespace NzbDrone.Core.Datastore } public Version Version => _database.Version; + public long Size => _database.Size; + public void Vacuum() { diff --git a/src/NzbDrone.Core/Datastore/MainDatabase.cs b/src/NzbDrone.Core/Datastore/MainDatabase.cs index 8ce09eaf2..066cb99c1 100644 --- a/src/NzbDrone.Core/Datastore/MainDatabase.cs +++ b/src/NzbDrone.Core/Datastore/MainDatabase.cs @@ -23,6 +23,7 @@ namespace NzbDrone.Core.Datastore } public Version Version => _database.Version; + public long Size => _database.Size; public void Vacuum() { diff --git a/src/NzbDrone.Core/Diagnostics/DiagnosticFeatureSwitches.cs b/src/NzbDrone.Core/Diagnostics/DiagnosticFeatureSwitches.cs new file mode 100644 index 000000000..9c966ef83 --- /dev/null +++ b/src/NzbDrone.Core/Diagnostics/DiagnosticFeatureSwitches.cs @@ -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; + } + } + } +} diff --git a/src/NzbDrone.Core/Diagnostics/DiagnosticScriptRunner.cs b/src/NzbDrone.Core/Diagnostics/DiagnosticScriptRunner.cs new file mode 100644 index 000000000..1b53df125 --- /dev/null +++ b/src/NzbDrone.Core/Diagnostics/DiagnosticScriptRunner.cs @@ -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 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 _scriptStateCache; + + private WeakReference _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(GetType()); + + _lastCompilation = new WeakReference(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 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 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 GetGlobalUsings(string source) + { + var result = new HashSet(); + + // Make the syntax easier by parsing Resolve 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 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 and auto add using + foreach (var ns in globalUsings) + { + options = options.AddImports(ns); + } + + return options; + } + + private List 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; + } + } +} diff --git a/src/NzbDrone.Core/Diagnostics/ScriptContext.cs b/src/NzbDrone.Core/Diagnostics/ScriptContext.cs new file mode 100644 index 000000000..bd7404c24 --- /dev/null +++ b/src/NzbDrone.Core/Diagnostics/ScriptContext.cs @@ -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() + where T : class + { + return _container.Resolve(); + } + } +} diff --git a/src/NzbDrone.Core/Diagnostics/ScriptDiagnostic.cs b/src/NzbDrone.Core/Diagnostics/ScriptDiagnostic.cs new file mode 100644 index 000000000..53a33bdd7 --- /dev/null +++ b/src/NzbDrone.Core/Diagnostics/ScriptDiagnostic.cs @@ -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(); + } + } +} diff --git a/src/NzbDrone.Core/Diagnostics/ScriptExecutionResult.cs b/src/NzbDrone.Core/Diagnostics/ScriptExecutionResult.cs new file mode 100644 index 000000000..197774bb6 --- /dev/null +++ b/src/NzbDrone.Core/Diagnostics/ScriptExecutionResult.cs @@ -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 Variables { get; set; } + public ScriptValidationResult Validation { get; set; } + } +} diff --git a/src/NzbDrone.Core/Diagnostics/ScriptRequest.cs b/src/NzbDrone.Core/Diagnostics/ScriptRequest.cs new file mode 100644 index 000000000..9cbdfda88 --- /dev/null +++ b/src/NzbDrone.Core/Diagnostics/ScriptRequest.cs @@ -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; } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Diagnostics/ScriptValidationResult.cs b/src/NzbDrone.Core/Diagnostics/ScriptValidationResult.cs new file mode 100644 index 000000000..02424657a --- /dev/null +++ b/src/NzbDrone.Core/Diagnostics/ScriptValidationResult.cs @@ -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); + + } +} diff --git a/src/NzbDrone.Core/Sonarr.Core.csproj b/src/NzbDrone.Core/Sonarr.Core.csproj index 48df7d8f2..c38f3917b 100644 --- a/src/NzbDrone.Core/Sonarr.Core.csproj +++ b/src/NzbDrone.Core/Sonarr.Core.csproj @@ -8,6 +8,7 @@ + diff --git a/src/NzbDrone.Integration.Test/DiagnosticsTests/DiagnosticsScriptModuleFixture.cs b/src/NzbDrone.Integration.Test/DiagnosticsTests/DiagnosticsScriptModuleFixture.cs new file mode 100644 index 000000000..f10674329 --- /dev/null +++ b/src/NzbDrone.Integration.Test/DiagnosticsTests/DiagnosticsScriptModuleFixture.cs @@ -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 DebugVariables { get; set; } + + public string Error { get; set; } + public List 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(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();\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();\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"); + } + } +} diff --git a/src/NzbDrone.Integration.Test/Sonarr.Integration.Test.csproj b/src/NzbDrone.Integration.Test/Sonarr.Integration.Test.csproj index f09998d8e..c1f9e5644 100644 --- a/src/NzbDrone.Integration.Test/Sonarr.Integration.Test.csproj +++ b/src/NzbDrone.Integration.Test/Sonarr.Integration.Test.csproj @@ -9,5 +9,6 @@ + diff --git a/src/NzbDrone.Test.Common/AutoMoqerContainer.cs b/src/NzbDrone.Test.Common/AutoMoqerContainer.cs new file mode 100644 index 000000000..4ff166fdf --- /dev/null +++ b/src/NzbDrone.Test.Common/AutoMoqerContainer.cs @@ -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 GetImplementations(Type contractType) + { + throw new NotImplementedException(); + } + + public bool IsTypeRegistered(Type type) + { + throw new NotImplementedException(); + } + + public void Register(T instance) where T : class + { + throw new NotImplementedException(); + } + + public void Register(Type serviceType, Type implementationType) + { + throw new NotImplementedException(); + } + + public void Register(Func factory) where TService : class + { + throw new NotImplementedException(); + } + + public void RegisterAllAsSingleton(Type registrationType, IEnumerable implementationList) + { + throw new NotImplementedException(); + } + + public void RegisterSingleton(Type service, Type implementation) + { + throw new NotImplementedException(); + } + + public T Resolve() where T : class + { + return _autoMoqer.Resolve(); + } + + public object Resolve(Type type) + { + throw new NotImplementedException(); + } + + public IEnumerable ResolveAll() where T : class + { + throw new NotImplementedException(); + } + + void IContainer.Register() + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Sonarr.Api.V3/Diagnostics/DiagnosticStatusModule.cs b/src/Sonarr.Api.V3/Diagnostics/DiagnosticStatusModule.cs new file mode 100644 index 000000000..90660a713 --- /dev/null +++ b/src/Sonarr.Api.V3/Diagnostics/DiagnosticStatusModule.cs @@ -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 + }; + } + } +} diff --git a/src/Sonarr.Api.V3/Diagnostics/ScriptRunnerModule.cs b/src/Sonarr.Api.V3/Diagnostics/ScriptRunnerModule.cs new file mode 100644 index 000000000..b796afc40 --- /dev/null +++ b/src/Sonarr.Api.V3/Diagnostics/ScriptRunnerModule.cs @@ -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(); + } + else if (Request.Headers.ContentType == "text/plain") + { + return new ScriptRequest { Code = Context.Request.Body.AsString() }; + } + else + { + return Request.Body.FromJson(); + } + } + + 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() + }; + } + } +} diff --git a/yarn.lock b/yarn.lock index e43b1f221..f05b90265 100644 --- a/yarn.lock +++ b/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"