Compare commits

...

4 Commits

48 changed files with 1926 additions and 24 deletions

View File

@ -3,7 +3,7 @@
# Uncomment this to turn on verbose mode. # Uncomment this to turn on verbose mode.
#export DH_VERBOSE=1 #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 dh $@ --with=systemd --with=cli

View File

@ -10,8 +10,7 @@ gulp.task('build',
'webpack', 'webpack',
'copyHtml', 'copyHtml',
'copyFonts', 'copyFonts',
'copyImages', 'copyImages'
'copyJs'
) )
) )
); );

View File

@ -5,17 +5,6 @@ const cache = require('gulp-cached');
const livereload = require('gulp-livereload'); const livereload = require('gulp-livereload');
const paths = require('./helpers/paths.js'); const paths = require('./helpers/paths.js');
gulp.task('copyJs', () => {
return gulp.src(
[
path.join(paths.src.root, 'polyfills.js')
], { base: paths.src.root })
.pipe(cache('copyJs'))
.pipe(print())
.pipe(gulp.dest(paths.dest.root))
.pipe(livereload());
});
gulp.task('copyHtml', () => { gulp.task('copyHtml', () => {
return gulp.src(paths.src.html, { base: paths.src.root }) return gulp.src(paths.src.html, { base: paths.src.root })
.pipe(cache('copyHtml')) .pipe(cache('copyHtml'))

View File

@ -13,6 +13,7 @@ const frontendFolder = path.join(__dirname, '..');
const srcFolder = path.join(frontendFolder, 'src'); const srcFolder = path.join(frontendFolder, 'src');
const isProduction = process.argv.indexOf('--production') > -1; const isProduction = process.argv.indexOf('--production') > -1;
const isProfiling = isProduction && process.argv.indexOf('--profile') > -1; const isProfiling = isProduction && process.argv.indexOf('--profile') > -1;
const inlineWebWorkers = true;
const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder); const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder);
@ -118,10 +119,17 @@ const config = {
rules: [ rules: [
{ {
test: /\.worker\.js$/, 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: { use: {
loader: 'worker-loader', loader: 'worker-loader',
options: { options: {
name: '[name].js' name: '[name].js',
inline: inlineWebWorkers,
fallback: !inlineWebWorkers
} }
} }
}, },

20
frontend/jsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es6",
"checkJs": false,
"baseUrl": "src",
"jsx": "react",
"module": "commonjs",
"moduleResolution": "node",
"paths": {
"*": [
"*"
]
}
},
"include": [
"./src/**/*"
],
"exclude": [
]
}

View File

@ -33,6 +33,7 @@ import BackupsConnector from 'System/Backup/BackupsConnector';
import UpdatesConnector from 'System/Updates/UpdatesConnector'; import UpdatesConnector from 'System/Updates/UpdatesConnector';
import LogsTableConnector from 'System/Events/LogsTableConnector'; import LogsTableConnector from 'System/Events/LogsTableConnector';
import Logs from 'System/Logs/Logs'; import Logs from 'System/Logs/Logs';
import Diagnostic from 'Diagnostic/Diagnostic';
function AppRoutes(props) { function AppRoutes(props) {
const { const {
@ -229,6 +230,15 @@ function AppRoutes(props) {
component={Logs} component={Logs}
/> />
{/*
Diagnostics
*/}
<Route
path="/diag"
component={Diagnostic}
/>
{/* {/*
Not Found Not Found
*/} */}

View File

@ -165,6 +165,23 @@ const links = [
to: '/system/logs/files' 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 isActiveParent = activeParent === link.to;
const hasActiveChild = hasActiveChildLink(link, pathname); const hasActiveChild = hasActiveChildLink(link, pathname);
if (link.hidden && !isActiveParent && !hasActiveChild) {
return null;
}
return ( return (
<PageSidebarItem <PageSidebarItem
key={link.to} key={link.to}

View File

@ -41,6 +41,7 @@ function PageToolbarButton(props) {
} }
PageToolbarButton.propTypes = { PageToolbarButton.propTypes = {
...Link.propTypes,
label: PropTypes.string.isRequired, label: PropTypes.string.isRequired,
iconName: PropTypes.object.isRequired, iconName: PropTypes.object.isRequired,
spinningName: PropTypes.object, 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, faSignOutAlt as fasSignOutAlt,
faSitemap as fasSitemap, faSitemap as fasSitemap,
faSpinner as fasSpinner, faSpinner as fasSpinner,
faStepForward as fasStepForward,
faSort as fasSort, faSort as fasSort,
faSortDown as fasSortDown, faSortDown as fasSortDown,
faSortUp as fasSortUp, faSortUp as fasSortUp,
@ -126,6 +127,7 @@ export const CLONE = farClone;
export const COLLAPSE = fasChevronCircleUp; export const COLLAPSE = fasChevronCircleUp;
export const COMPUTER = fasDesktop; export const COMPUTER = fasDesktop;
export const DANGER = fasExclamationCircle; export const DANGER = fasExclamationCircle;
export const DEBUG = fasBug;
export const DELETE = fasTrashAlt; export const DELETE = fasTrashAlt;
export const DOWNLOAD = fasDownload; export const DOWNLOAD = fasDownload;
export const DOWNLOADED = fasDownload; export const DOWNLOADED = fasDownload;
@ -180,6 +182,8 @@ export const REORDER = fasBars;
export const RSS = fasRss; export const RSS = fasRss;
export const SAVE = fasSave; export const SAVE = fasSave;
export const SCHEDULED = farClock; export const SCHEDULED = farClock;
export const SCRIPT_DEBUG = fasStepForward;
export const SCRIPT_RUN = fasPlay;
export const SCORE = fasUserPlus; export const SCORE = fasUserPlus;
export const SEARCH = fasSearch; export const SEARCH = fasSearch;
export const SERIES_CONTINUING = fasPlay; 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 captcha from './captchaActions';
import * as customFilters from './customFilterActions'; import * as customFilters from './customFilterActions';
import * as commands from './commandActions'; import * as commands from './commandActions';
import * as diagnostic from './diagnosticActions';
import * as episodes from './episodeActions'; import * as episodes from './episodeActions';
import * as episodeFiles from './episodeFileActions'; import * as episodeFiles from './episodeFileActions';
import * as episodeHistory from './episodeHistoryActions'; import * as episodeHistory from './episodeHistoryActions';
@ -36,6 +37,7 @@ export default [
captcha, captcha,
commands, commands,
customFilters, customFilters,
diagnostic,
episodes, episodes,
episodeFiles, episodeFiles,
episodeHistory, episodeHistory,

View File

@ -79,6 +79,5 @@
</body> </body>
<script src="/initialize.js" data-no-hash></script> <script src="/initialize.js" data-no-hash></script>
<script src="/polyfills.js"></script>
<!-- webpack bundles body --> <!-- webpack bundles body -->
</html> </html>

View File

@ -1,4 +1,6 @@
import './preload.js'; import './preload';
import './polyfills';
import React from 'react'; import React from 'react';
import { render } from 'react-dom'; import { render } from 'react-dom';
import { createBrowserHistory } from 'history'; import { createBrowserHistory } from 'history';

View File

@ -77,6 +77,7 @@
"mini-css-extract-plugin": "0.8.0", "mini-css-extract-plugin": "0.8.0",
"mobile-detect": "1.4.3", "mobile-detect": "1.4.3",
"moment": "2.24.0", "moment": "2.24.0",
"monaco-editor": "0.20.0",
"mousetrap": "1.6.3", "mousetrap": "1.6.3",
"normalize.css": "8.0.1", "normalize.css": "8.0.1",
"postcss-color-function": "4.1.0", "postcss-color-function": "4.1.0",
@ -100,6 +101,7 @@
"react-google-recaptcha": "1.1.0", "react-google-recaptcha": "1.1.0",
"react-lazyload": "2.6.2", "react-lazyload": "2.6.2",
"react-measure": "1.4.7", "react-measure": "1.4.7",
"react-monaco-editor": "0.36.0",
"react-popper": "1.3.3", "react-popper": "1.3.3",
"react-redux": "7.1.0", "react-redux": "7.1.0",
"react-router": "5.0.1", "react-router": "5.0.1",

View File

@ -11,7 +11,7 @@ using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Api.Indexers namespace NzbDrone.Api.Indexers
{ {
class ReleasePushModule : ReleaseModuleBase public class ReleasePushModule : ReleaseModuleBase
{ {
private readonly IMakeDownloadDecision _downloadDecisionMaker; private readonly IMakeDownloadDecision _downloadDecisionMaker;
private readonly IProcessDownloadDecisions _downloadDecisionProcessor; private readonly IProcessDownloadDecisions _downloadDecisionProcessor;

View File

@ -3,7 +3,7 @@ using Nancy;
namespace NzbDrone.Api.Profiles namespace NzbDrone.Api.Profiles
{ {
class LegacyProfileModule : NzbDroneApiModule public class LegacyProfileModule : NzbDroneApiModule
{ {
public LegacyProfileModule() public LegacyProfileModule()
: base("qualityprofile") : base("qualityprofile")

View File

@ -3,7 +3,7 @@ using Nancy;
namespace NzbDrone.Api.Wanted namespace NzbDrone.Api.Wanted
{ {
class LegacyMissingModule : NzbDroneApiModule public class LegacyMissingModule : NzbDroneApiModule
{ {
public LegacyMissingModule() : base("missing") public LegacyMissingModule() : base("missing")
{ {

View File

@ -23,7 +23,7 @@ namespace NzbDrone.Common.Composition
foreach (var assembly in assemblies) foreach (var assembly in assemblies)
{ {
_loadedTypes.AddRange(Assembly.Load(assembly).GetTypes()); _loadedTypes.AddRange(Assembly.Load(assembly).GetExportedTypes());
} }
Container = new Container(new TinyIoCContainer(), _loadedTypes); Container = new Container(new TinyIoCContainer(), _loadedTypes);

View File

@ -17,7 +17,7 @@ namespace NzbDrone.Common.Reflection
public static List<Type> ImplementationsOf<T>(this Assembly assembly) public static List<Type> ImplementationsOf<T>(this Assembly assembly)
{ {
return assembly.GetTypes().Where(c => typeof(T).IsAssignableFrom(c)).ToList(); return assembly.GetExportedTypes().Where(c => typeof(T).IsAssignableFrom(c)).ToList();
} }
public static bool IsSimpleType(this Type type) public static bool IsSimpleType(this Type type)
@ -67,7 +67,7 @@ namespace NzbDrone.Common.Reflection
public static Type FindTypeByName(this Assembly assembly, string name) public static Type FindTypeByName(this Assembly assembly, string name)
{ {
return assembly.GetTypes().SingleOrDefault(c => c.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)); return assembly.GetExportedTypes().SingleOrDefault(c => c.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase));
} }
public static bool HasAttribute<TAttribute>(this Type type) public static bool HasAttribute<TAttribute>(this Type type)

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 @@ namespace NzbDrone.Core.Datastore
{ {
IDataMapper GetDataMapper(); IDataMapper GetDataMapper();
Version Version { get; } Version Version { get; }
long Size { get; }
void Vacuum(); 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() public void Vacuum()
{ {
try try

View File

@ -40,6 +40,7 @@ namespace NzbDrone.Core.Datastore
Environment.SetEnvironmentVariable("No_Expand", "true"); Environment.SetEnvironmentVariable("No_Expand", "true");
Environment.SetEnvironmentVariable("No_SQLiteXmlConfigFile", "true"); Environment.SetEnvironmentVariable("No_SQLiteXmlConfigFile", "true");
Environment.SetEnvironmentVariable("No_PreLoadSQLite", "true"); Environment.SetEnvironmentVariable("No_PreLoadSQLite", "true");
Environment.SetEnvironmentVariable("No_SQLiteFunctions", "true");
} }
public static void RegisterDatabase(IContainer container) public static void RegisterDatabase(IContainer container)

View File

@ -23,6 +23,8 @@ namespace NzbDrone.Core.Datastore
} }
public Version Version => _database.Version; public Version Version => _database.Version;
public long Size => _database.Size;
public void Vacuum() public void Vacuum()
{ {

View File

@ -23,6 +23,7 @@ namespace NzbDrone.Core.Datastore
} }
public Version Version => _database.Version; public Version Version => _database.Version;
public long Size => _database.Size;
public void Vacuum() 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="FluentValidation" Version="8.4.0" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="1.0.0-beta0007" /> <PackageReference Include="SixLabors.ImageSharp.Drawing" Version="1.0.0-beta0007" />
<PackageReference Include="System.Numerics.Vectors" Version="4.5.0" /> <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="Newtonsoft.Json" Version="12.0.2" />
<PackageReference Include="NLog" Version="4.6.6" /> <PackageReference Include="NLog" Version="4.6.6" />
<PackageReference Include="OAuth" Version="1.0.3" /> <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> <ItemGroup>
<ProjectReference Include="..\NzbDrone.Api\Sonarr.Api.csproj" /> <ProjectReference Include="..\NzbDrone.Api\Sonarr.Api.csproj" />
<ProjectReference Include="..\NzbDrone.Test.Common\Sonarr.Test.Common.csproj" /> <ProjectReference Include="..\NzbDrone.Test.Common\Sonarr.Test.Common.csproj" />
<ProjectReference Include="..\Sonarr.Api.V3\Sonarr.Api.V3.csproj" />
</ItemGroup> </ItemGroup>
</Project> </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

@ -12,7 +12,7 @@ using NzbDrone.Core.Parser.Model;
namespace Sonarr.Api.V3.Indexers namespace Sonarr.Api.V3.Indexers
{ {
class ReleasePushModule : ReleaseModuleBase public class ReleasePushModule : ReleaseModuleBase
{ {
private readonly IMakeDownloadDecision _downloadDecisionMaker; private readonly IMakeDownloadDecision _downloadDecisionMaker;
private readonly IProcessDownloadDecisions _downloadDecisionProcessor; private readonly IProcessDownloadDecisions _downloadDecisionProcessor;

View File

@ -1080,6 +1080,14 @@
"@types/prop-types" "*" "@types/prop-types" "*"
csstype "^2.2.0" 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": "@types/shallowequal@^1.1.1":
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/@types/shallowequal/-/shallowequal-1.1.1.tgz#aad262bb3f2b1257d94c71d545268d592575c9b1" 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" resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== 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: mousetrap@1.6.3:
version "1.6.3" version "1.6.3"
resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.3.tgz#80fee49665fd478bccf072c9d46bdf1bfed3558a" 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" prop-types "^15.5.4"
resize-observer-polyfill "^1.4.1" 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: react-popper@1.3.3:
version "1.3.3" version "1.3.3"
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-1.3.3.tgz#2c6cef7515a991256b4f0536cd4bdcb58a7b6af6" resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-1.3.3.tgz#2c6cef7515a991256b4f0536cd4bdcb58a7b6af6"