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