Browse Source

Performance/Webpack: Introduces more aggressive code-splitting and other perf improvements (#18544)

* Performance/Webpack: Introduces more aggressive code-splitting and other perf improvements
- Introduces dynamic imports for built-in plugins
- Uses dynamic imports for various packages (rst2html, brace)
- Introduces route-based dynamic imports
- Splits angular and moment into separate bundles
kay delaney 6 years ago
parent
commit
7985aa1e57
34 changed files with 289 additions and 200 deletions
  1. 5 2
      .babelrc
  2. 3 0
      .gitignore
  3. 1 0
      package.json
  4. 3 2
      packages/grafana-data/src/utils/fieldReducer.test.ts
  5. 4 4
      packages/grafana-data/src/utils/rangeutil.ts
  6. 1 1
      packages/grafana-toolkit/src/config/webpack.plugin.config.ts
  7. 1 1
      packages/grafana-ui/src/components/Graph/GraphWithLegend.tsx
  8. 5 5
      public/app/core/components/Footer/Footer.tsx
  9. 4 0
      public/app/core/components/code_editor/brace.d.ts
  10. 18 15
      public/app/core/components/code_editor/code_editor.ts
  11. 4 0
      public/app/features/admin/__snapshots__/ServerStats.test.tsx.snap
  12. 2 0
      public/app/features/datasources/NewDataSourcePage.tsx
  13. 1 0
      public/app/features/explore/NoDataSourceCallToAction.tsx
  14. 4 3
      public/app/features/explore/QueryRow.tsx
  15. 1 1
      public/app/features/plugins/PluginPage.tsx
  16. 34 18
      public/app/features/plugins/built_in_plugins.ts
  17. 7 2
      public/app/features/plugins/plugin_loader.ts
  18. 1 1
      public/app/features/users/UsersActionBar.tsx
  19. 1 0
      public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap
  20. 14 9
      public/app/plugins/datasource/graphite/FunctionEditor.tsx
  21. 3 3
      public/app/plugins/datasource/graphite/add_graphite_func.ts
  22. 1 1
      public/app/plugins/datasource/prometheus/components/PromLink.tsx
  23. 0 1
      public/app/plugins/datasource/stackdriver/components/Alignments.tsx
  24. 0 2
      public/app/plugins/datasource/stackdriver/query_ctrl.ts
  25. 6 1
      public/app/plugins/datasource/testdata/TestInfoTab.tsx
  26. 32 43
      public/app/routes/routes.ts
  27. 3 0
      public/sass/base/_fonts.scss
  28. 6 4
      public/test/specs/helpers.ts
  29. 1 0
      public/views/index-template.html
  30. 2 2
      scripts/webpack/dependencies.js
  31. 56 30
      scripts/webpack/webpack.common.js
  32. 24 18
      scripts/webpack/webpack.dev.js
  33. 33 27
      scripts/webpack/webpack.hot.js
  34. 8 4
      scripts/webpack/webpack.prod.js

+ 5 - 2
.babelrc

@@ -3,8 +3,11 @@
     [
       "@babel/preset-env",
       {
-		  "targets": { "browsers": "last 3 versions" },
-		  "useBuiltIns": "entry"
+        "targets": {
+          "browsers": "last 3 versions"
+        },
+        "useBuiltIns": "entry",
+        "modules": "false",
       }
     ]
   ]

+ 3 - 0
.gitignore

@@ -94,3 +94,6 @@ theOutput/
 
 # Ignore go local build dependencies
 /scripts/go/bin/**
+
+# Ignore compilation stats from `yarn stats`
+compilation-stats.json

+ 1 - 0
package.json

@@ -147,6 +147,7 @@
     "start:hot": "grafana-toolkit core:start --hot --watchTheme",
     "start:ignoreTheme": "grafana-toolkit core:start --hot",
     "start:noTsCheck": "grafana-toolkit core:start --noTsCheck",
+    "stats": "webpack --mode production --config scripts/webpack/webpack.prod.js --profile --json > compilation-stats.json",
     "watch": "yarn start -d watch,start core:start --watchTheme ",
     "build": "grunt build",
     "test": "grunt test",

+ 3 - 2
packages/grafana-data/src/utils/fieldReducer.test.ts

@@ -1,6 +1,7 @@
+import difference from 'lodash/difference';
+
 import { fieldReducers, ReducerID, reduceField } from './fieldReducer';
 
-import _ from 'lodash';
 import { Field, FieldType } from '../types/index';
 import { MutableDataFrame } from './dataFrameHelper';
 import { ArrayVector } from './vector';
@@ -42,7 +43,7 @@ describe('Stats Calculators', () => {
     expect(stats.length).toBe(2);
 
     const found = stats.map(v => v.id);
-    const notFound = _.difference(names, found);
+    const notFound = difference(names, found);
     expect(notFound.length).toBe(2);
 
     expect(notFound[0]).toBe('not a stat');

+ 4 - 4
packages/grafana-data/src/utils/rangeutil.ts

@@ -1,5 +1,5 @@
-// @ts-ignore
-import _ from 'lodash';
+import each from 'lodash/each';
+import groupBy from 'lodash/groupBy';
 
 import { RawTimeRange } from '../types/time';
 
@@ -64,12 +64,12 @@ const rangeOptions = [
 const absoluteFormat = 'YYYY-MM-DD HH:mm:ss';
 
 const rangeIndex: any = {};
-_.each(rangeOptions, (frame: any) => {
+each(rangeOptions, (frame: any) => {
   rangeIndex[frame.from + ' to ' + frame.to] = frame;
 });
 
 export function getRelativeTimesList(timepickerSettings: any, currentDisplay: any) {
-  const groups = _.groupBy(rangeOptions, (option: any) => {
+  const groups = groupBy(rangeOptions, (option: any) => {
     option.active = option.display === currentDisplay;
     return option.section;
   });

+ 1 - 1
packages/grafana-toolkit/src/config/webpack.plugin.config.ts

@@ -183,7 +183,7 @@ export const getWebpackConfig: WebpackConfigurationGetter = options => {
             {
               loader: 'babel-loader',
               options: {
-                presets: ['@babel/preset-env'],
+                presets: ['@babel/preset-env', { modules: false }],
                 plugins: ['angularjs-annotate'],
               },
             },

+ 1 - 1
packages/grafana-ui/src/components/Graph/GraphWithLegend.tsx

@@ -1,5 +1,5 @@
 // Libraries
-import _ from 'lodash';
+
 import React from 'react';
 import { css } from 'emotion';
 import { GraphSeriesValue, AbsoluteTimeRange } from '@grafana/data';

+ 5 - 5
public/app/core/components/Footer/Footer.tsx

@@ -16,22 +16,22 @@ export const Footer: FC<Props> = React.memo(
         <div className="text-center">
           <ul>
             <li>
-              <a href="http://docs.grafana.org" target="_blank">
+              <a href="http://docs.grafana.org" target="_blank" rel="noopener">
                 <i className="fa fa-file-code-o" /> Docs
               </a>
             </li>
             <li>
-              <a href="https://grafana.com/services/support" target="_blank">
+              <a href="https://grafana.com/services/support" target="_blank" rel="noopener">
                 <i className="fa fa-support" /> Support Plans
               </a>
             </li>
             <li>
-              <a href="https://community.grafana.com/" target="_blank">
+              <a href="https://community.grafana.com/" target="_blank" rel="noopener">
                 <i className="fa fa-comments-o" /> Community
               </a>
             </li>
             <li>
-              <a href="https://grafana.com" target="_blank">
+              <a href="https://grafana.com" target="_blank" rel="noopener">
                 {appName}
               </a>{' '}
               <span>
@@ -41,7 +41,7 @@ export const Footer: FC<Props> = React.memo(
             {newGrafanaVersionExists && (
               <li>
                 <Tooltip placement="auto" content={newGrafanaVersion}>
-                  <a href="https://grafana.com/get" target="_blank">
+                  <a href="https://grafana.com/get" target="_blank" rel="noopener">
                     New version available!
                   </a>
                 </Tooltip>

+ 4 - 0
public/app/core/components/code_editor/brace.d.ts

@@ -0,0 +1,4 @@
+declare module 'brace/*' {
+  let brace: any;
+  export default brace;
+}

+ 18 - 15
public/app/core/components/code_editor/code_editor.ts

@@ -30,20 +30,6 @@
 
 import coreModule from 'app/core/core_module';
 import config from 'app/core/config';
-import ace from 'brace';
-import './theme-grafana-dark';
-import 'brace/ext/language_tools';
-import 'brace/theme/textmate';
-import 'brace/mode/text';
-import 'brace/snippets/text';
-import 'brace/mode/sql';
-import 'brace/snippets/sql';
-import 'brace/mode/sqlserver';
-import 'brace/snippets/sqlserver';
-import 'brace/mode/markdown';
-import 'brace/snippets/markdown';
-import 'brace/mode/json';
-import 'brace/snippets/json';
 
 const DEFAULT_THEME_DARK = 'ace/theme/grafana-dark';
 const DEFAULT_THEME_LIGHT = 'ace/theme/textmate';
@@ -55,7 +41,7 @@ const DEFAULT_SNIPPETS = true;
 
 const editorTemplate = `<div></div>`;
 
-function link(scope: any, elem: any, attrs: any) {
+async function link(scope: any, elem: any, attrs: any) {
   // Options
   const langMode = attrs.mode || DEFAULT_MODE;
   const maxLines = attrs.maxLines || DEFAULT_MAX_LINES;
@@ -66,6 +52,23 @@ function link(scope: any, elem: any, attrs: any) {
 
   // Initialize editor
   const aceElem = elem.get(0);
+  const { default: ace } = await import(/* webpackChunkName: "brace" */ 'brace');
+  await import('brace/ext/language_tools');
+  await import('brace/theme/textmate');
+  await import('brace/mode/text');
+  await import('brace/snippets/text');
+  await import('brace/mode/sql');
+  await import('brace/snippets/sql');
+  await import('brace/mode/sqlserver');
+  await import('brace/snippets/sqlserver');
+  await import('brace/mode/markdown');
+  await import('brace/snippets/markdown');
+  await import('brace/mode/json');
+  await import('brace/snippets/json');
+
+  // @ts-ignore
+  await import('./theme-grafana-dark');
+
   const codeEditor = ace.edit(aceElem);
   const editorSession = codeEditor.getSession();
 

+ 4 - 0
public/app/features/admin/__snapshots__/ServerStats.test.tsx.snap

@@ -160,6 +160,7 @@ exports[`ServerStats Should render table with stats 1`] = `
               <li>
                 <a
                   href="http://docs.grafana.org"
+                  rel="noopener"
                   target="_blank"
                 >
                   <i
@@ -171,6 +172,7 @@ exports[`ServerStats Should render table with stats 1`] = `
               <li>
                 <a
                   href="https://grafana.com/services/support"
+                  rel="noopener"
                   target="_blank"
                 >
                   <i
@@ -182,6 +184,7 @@ exports[`ServerStats Should render table with stats 1`] = `
               <li>
                 <a
                   href="https://community.grafana.com/"
+                  rel="noopener"
                   target="_blank"
                 >
                   <i
@@ -193,6 +196,7 @@ exports[`ServerStats Should render table with stats 1`] = `
               <li>
                 <a
                   href="https://grafana.com"
+                  rel="noopener"
                   target="_blank"
                 >
                   Grafana

+ 2 - 0
public/app/features/datasources/NewDataSourcePage.tsx

@@ -131,6 +131,7 @@ class NewDataSourcePage extends PureComponent<Props> {
             className="btn btn-inverse"
             href="https://grafana.com/plugins?type=datasource&utm_source=new-data-source"
             target="_blank"
+            rel="noopener"
           >
             Find more data source plugins on grafana.com
           </a>
@@ -198,6 +199,7 @@ const DataSourceTypeCard: FC<DataSourceTypeCardProps> = props => {
             className="btn btn-inverse"
             href={`${learnMoreLink}?utm_source=grafana_add_ds`}
             target="_blank"
+            rel="noopener"
             onClick={onLearnMoreClick}
           >
             Learn more <i className="fa fa-external-link add-datasource-item-actions__btn-icon" />

+ 1 - 0
public/app/features/explore/NoDataSourceCallToAction.tsx

@@ -14,6 +14,7 @@ export const NoDataSourceCallToAction = () => {
       <a
         href="http://docs.grafana.org/administration/provisioning/#datasources?utm_source=explore"
         target="_blank"
+        rel="noopener"
         className="text-link"
       >
         Learn more

+ 4 - 3
public/app/features/explore/QueryRow.tsx

@@ -1,6 +1,7 @@
 // Libraries
 import React, { PureComponent } from 'react';
-import _ from 'lodash';
+import debounce from 'lodash/debounce';
+import has from 'lodash/has';
 import { hot } from 'react-hot-loader';
 // @ts-ignore
 import { connect } from 'react-redux';
@@ -97,7 +98,7 @@ export class QueryRow extends PureComponent<QueryRowProps, QueryRowState> {
     this.setState({ textEditModeEnabled: !this.state.textEditModeEnabled });
   };
 
-  updateLogsHighlights = _.debounce((value: DataQuery) => {
+  updateLogsHighlights = debounce((value: DataQuery) => {
     const { datasourceInstance } = this.props;
     if (datasourceInstance.getHighlighterExpression) {
       const { exploreId } = this.props;
@@ -120,7 +121,7 @@ export class QueryRow extends PureComponent<QueryRowProps, QueryRowState> {
       mode,
     } = this.props;
     const canToggleEditorModes =
-      mode === ExploreMode.Metrics && _.has(datasourceInstance, 'components.QueryCtrl.prototype.toggleEditorMode');
+      mode === ExploreMode.Metrics && has(datasourceInstance, 'components.QueryCtrl.prototype.toggleEditorMode');
     const queryErrors = queryResponse.error && queryResponse.error.refId === query.refId ? [queryResponse.error] : [];
     let QueryField;
 

+ 1 - 1
public/app/features/plugins/PluginPage.tsx

@@ -278,7 +278,7 @@ class PluginPage extends PureComponent<Props, State> {
           {info.links.map(link => {
             return (
               <li key={link.url}>
-                <a href={link.url} className="external-link" target="_blank">
+                <a href={link.url} className="external-link" target="_blank" rel="noopener">
                   {link.name}
                 </a>
               </li>

+ 34 - 18
public/app/features/plugins/built_in_plugins.ts

@@ -1,20 +1,36 @@
-import * as graphitePlugin from 'app/plugins/datasource/graphite/module';
-import * as cloudwatchPlugin from 'app/plugins/datasource/cloudwatch/module';
-import * as dashboardDSPlugin from 'app/plugins/datasource/dashboard/module';
-import * as elasticsearchPlugin from 'app/plugins/datasource/elasticsearch/module';
-import * as opentsdbPlugin from 'app/plugins/datasource/opentsdb/module';
-import * as grafanaPlugin from 'app/plugins/datasource/grafana/module';
-import * as influxdbPlugin from 'app/plugins/datasource/influxdb/module';
-import * as lokiPlugin from 'app/plugins/datasource/loki/module';
-import * as mixedPlugin from 'app/plugins/datasource/mixed/module';
-import * as mysqlPlugin from 'app/plugins/datasource/mysql/module';
-import * as postgresPlugin from 'app/plugins/datasource/postgres/module';
-import * as prometheusPlugin from 'app/plugins/datasource/prometheus/module';
-import * as mssqlPlugin from 'app/plugins/datasource/mssql/module';
-import * as testDataDSPlugin from 'app/plugins/datasource/testdata/module';
-import * as inputDatasourcePlugin from 'app/plugins/datasource/input/module';
-import * as stackdriverPlugin from 'app/plugins/datasource/stackdriver/module';
-import * as azureMonitorPlugin from 'app/plugins/datasource/grafana-azure-monitor-datasource/module';
+const graphitePlugin = async () =>
+  await import(/* webpackChunkName: "graphitePlugin" */ 'app/plugins/datasource/graphite/module');
+const cloudwatchPlugin = async () =>
+  await import(/* webpackChunkName: "cloudwatchPlugin" */ 'app/plugins/datasource/cloudwatch/module');
+const dashboardDSPlugin = async () =>
+  await import(/* webpackChunkName "dashboardDSPlugin" */ 'app/plugins/datasource/dashboard/module');
+const elasticsearchPlugin = async () =>
+  await import(/* webpackChunkName: "elasticsearchPlugin" */ 'app/plugins/datasource/elasticsearch/module');
+const opentsdbPlugin = async () =>
+  await import(/* webpackChunkName: "opentsdbPlugin" */ 'app/plugins/datasource/opentsdb/module');
+const grafanaPlugin = async () =>
+  await import(/* webpackChunkName: "grafanaPlugin" */ 'app/plugins/datasource/grafana/module');
+const influxdbPlugin = async () =>
+  await import(/* webpackChunkName: "influxdbPlugin" */ 'app/plugins/datasource/influxdb/module');
+const lokiPlugin = async () => await import(/* webpackChunkName: "lokiPlugin" */ 'app/plugins/datasource/loki/module');
+const mixedPlugin = async () =>
+  await import(/* webpackChunkName: "mixedPlugin" */ 'app/plugins/datasource/mixed/module');
+const mysqlPlugin = async () =>
+  await import(/* webpackChunkName: "mysqlPlugin" */ 'app/plugins/datasource/mysql/module');
+const postgresPlugin = async () =>
+  await import(/* webpackChunkName: "postgresPlugin" */ 'app/plugins/datasource/postgres/module');
+const prometheusPlugin = async () =>
+  await import(/* webpackChunkName: "prometheusPlugin" */ 'app/plugins/datasource/prometheus/module');
+const mssqlPlugin = async () =>
+  await import(/* webpackChunkName: "mssqlPlugin" */ 'app/plugins/datasource/mssql/module');
+const testDataDSPlugin = async () =>
+  await import(/* webpackChunkName: "testDataDSPlugin" */ 'app/plugins/datasource/testdata/module');
+const inputDatasourcePlugin = async () =>
+  await import(/* webpackChunkName: "inputDatasourcePlugin" */ 'app/plugins/datasource/input/module');
+const stackdriverPlugin = async () =>
+  await import(/* webpackChunkName: "stackdriverPlugin" */ 'app/plugins/datasource/stackdriver/module');
+const azureMonitorPlugin = async () =>
+  await import(/* webpackChunkName: "azureMonitorPlugin" */ 'app/plugins/datasource/grafana-azure-monitor-datasource/module');
 
 import * as textPanel from 'app/plugins/panel/text/module';
 import * as text2Panel from 'app/plugins/panel/text2/module';
@@ -35,7 +51,7 @@ import * as pieChartPanel from 'app/plugins/panel/piechart/module';
 import * as barGaugePanel from 'app/plugins/panel/bargauge/module';
 import * as logsPanel from 'app/plugins/panel/logs/module';
 
-import * as exampleApp from 'app/plugins/app/example-app/module';
+const exampleApp = async () => await import(/* webpackChunkName: "exampleApp" */ 'app/plugins/app/example-app/module');
 
 const builtInPlugins: any = {
   'app/plugins/datasource/graphite/module': graphitePlugin,

+ 7 - 2
public/app/features/plugins/plugin_loader.ts

@@ -162,10 +162,15 @@ for (const flotDep of flotDeps) {
   exposeToPlugin(flotDep, { fakeDep: 1 });
 }
 
-export function importPluginModule(path: string): Promise<any> {
+export async function importPluginModule(path: string): Promise<any> {
   const builtIn = builtInPlugins[path];
   if (builtIn) {
-    return Promise.resolve(builtIn);
+    // for handling dynamic imports
+    if (typeof builtIn === 'function') {
+      return await builtIn();
+    } else {
+      return Promise.resolve(builtIn);
+    }
   }
   return grafanaRuntime.SystemJS.import(path);
 }

+ 1 - 1
public/app/features/users/UsersActionBar.tsx

@@ -68,7 +68,7 @@ export class UsersActionBar extends PureComponent<Props> {
             </a>
           )}
           {externalUserMngLinkUrl && (
-            <a className="btn btn-primary" href={externalUserMngLinkUrl} target="_blank">
+            <a className="btn btn-primary" href={externalUserMngLinkUrl} target="_blank" rel="noopener">
               <i className="fa fa-external-link-square" /> {externalUserMngLinkName}
             </a>
           )}

+ 1 - 0
public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap

@@ -86,6 +86,7 @@ exports[`Render should show external user management button 1`] = `
     <a
       className="btn btn-primary"
       href="some/url"
+      rel="noopener"
       target="_blank"
     >
       <i

+ 14 - 9
public/app/plugins/datasource/graphite/FunctionEditor.tsx

@@ -1,8 +1,6 @@
-import React from 'react';
+import React, { Suspense } from 'react';
 import { PopoverController, Popover } from '@grafana/ui';
-// @ts-ignore
-import rst2html from 'rst2html';
-import { FunctionDescriptor, FunctionEditorControlsProps, FunctionEditorControls } from './FunctionEditorControls';
+import { FunctionDescriptor, FunctionEditorControls, FunctionEditorControlsProps } from './FunctionEditorControls';
 
 interface FunctionEditorProps extends FunctionEditorControlsProps {
   func: FunctionDescriptor;
@@ -11,6 +9,15 @@ interface FunctionEditorProps extends FunctionEditorControlsProps {
 interface FunctionEditorState {
   showingDescription: boolean;
 }
+const FunctionDescription = React.lazy(async () => {
+  // @ts-ignore
+  const { default: rst2html } = await import(/* webpackChunkName: "rst2html" */ 'rst2html');
+  return {
+    default: (props: { description: string }) => (
+      <div dangerouslySetInnerHTML={{ __html: rst2html(props.description) }} />
+    ),
+  };
+});
 
 class FunctionEditor extends React.PureComponent<FunctionEditorProps, FunctionEditorState> {
   private triggerRef = React.createRef<HTMLSpanElement>();
@@ -37,11 +44,9 @@ class FunctionEditor extends React.PureComponent<FunctionEditorProps, FunctionEd
       return (
         <div style={{ overflow: 'auto', maxHeight: '30rem', textAlign: 'left', fontWeight: 'normal' }}>
           <h4 style={{ color: 'white' }}> {name} </h4>
-          <div
-            dangerouslySetInnerHTML={{
-              __html: rst2html(description),
-            }}
-          />
+          <Suspense fallback={<span>Loading description...</span>}>
+            <FunctionDescription description={description} />
+          </Suspense>
         </div>
       );
     }

+ 3 - 3
public/app/plugins/datasource/graphite/add_graphite_func.ts

@@ -1,8 +1,6 @@
 import _ from 'lodash';
 import $ from 'jquery';
 // @ts-ignore
-import rst2html from 'rst2html';
-// @ts-ignore
 import Drop from 'tether-drop';
 import coreModule from 'app/core/core_module';
 import { FuncDef } from './gfunc';
@@ -93,7 +91,7 @@ export function graphiteAddFunc($compile: any) {
       };
 
       $(elem)
-        .on('mouseenter', 'ul.dropdown-menu li', () => {
+        .on('mouseenter', 'ul.dropdown-menu li', async () => {
           cleanUpDrop();
 
           let funcDef;
@@ -110,6 +108,8 @@ export function graphiteAddFunc($compile: any) {
             }
 
             const contentElement = document.createElement('div');
+            // @ts-ignore
+            const { default: rst2html } = await import(/* webpackChunkName: "rst2html" */ 'rst2html');
             contentElement.innerHTML = '<h4>' + funcDef.name + '</h4>' + rst2html(shortDesc);
 
             drop = new Drop({

+ 1 - 1
public/app/plugins/datasource/prometheus/components/PromLink.tsx

@@ -60,7 +60,7 @@ export default class PromLink extends Component<Props, State> {
   render() {
     const { href } = this.state;
     return (
-      <a href={href} target="_blank">
+      <a href={href} target="_blank" rel="noopener">
         <i className="fa fa-share-square-o" /> Prometheus
       </a>
     );

+ 0 - 1
public/app/plugins/datasource/stackdriver/components/Alignments.tsx

@@ -1,5 +1,4 @@
 import React, { FC } from 'react';
-import _ from 'lodash';
 
 import { MetricSelect } from 'app/core/components/Select/MetricSelect';
 import { TemplateSrv } from 'app/features/templating/template_srv';

+ 0 - 2
public/app/plugins/datasource/stackdriver/query_ctrl.ts

@@ -1,5 +1,3 @@
-import _ from 'lodash';
-
 import { QueryCtrl } from 'app/plugins/sdk';
 import { StackdriverQuery } from './types';
 import { TemplateSrv } from 'app/features/templating/template_srv';

+ 6 - 1
public/app/plugins/datasource/testdata/TestInfoTab.tsx

@@ -18,7 +18,12 @@ export class TestInfoTab extends PureComponent<Props> {
         See github for more information about setting up a reproducable test environment.
         <br />
         <br />
-        <a className="btn btn-inverse" href="https://github.com/grafana/grafana/tree/master/devenv" target="_blank">
+        <a
+          className="btn btn-inverse"
+          href="https://github.com/grafana/grafana/tree/master/devenv"
+          target="_blank"
+          rel="noopener"
+        >
           Github
         </a>
         <br />

+ 32 - 43
public/app/routes/routes.ts

@@ -3,28 +3,9 @@ import './ReactContainer';
 import { applyRouteRegistrationHandlers } from './registry';
 
 // Pages
-import ChangePasswordPage from 'app/features/profile/ChangePasswordPage';
-import ServerStats from 'app/features/admin/ServerStats';
-import AlertRuleList from 'app/features/alerting/AlertRuleList';
-import TeamPages from 'app/features/teams/TeamPages';
-import TeamList from 'app/features/teams/TeamList';
-import ApiKeys from 'app/features/api-keys/ApiKeysPage';
-import PluginListPage from 'app/features/plugins/PluginListPage';
-import FolderSettingsPage from 'app/features/folders/FolderSettingsPage';
-import FolderPermissions from 'app/features/folders/FolderPermissions';
 import CreateFolderCtrl from 'app/features/folders/CreateFolderCtrl';
 import FolderDashboardsCtrl from 'app/features/folders/FolderDashboardsCtrl';
 import DashboardImportCtrl from 'app/features/manage-dashboards/DashboardImportCtrl';
-import DataSourcesListPage from 'app/features/datasources/DataSourcesListPage';
-import NewDataSourcePage from '../features/datasources/NewDataSourcePage';
-import UsersListPage from 'app/features/users/UsersListPage';
-import DataSourceDashboards from 'app/features/datasources/DataSourceDashboards';
-import DataSourceSettingsPage from '../features/datasources/settings/DataSourceSettingsPage';
-import OrgDetailsPage from '../features/org/OrgDetailsPage';
-import SoloPanelPage from '../features/dashboard/containers/SoloPanelPage';
-import DashboardPage from '../features/dashboard/containers/DashboardPage';
-import PluginPage from '../features/plugins/PluginPage';
-import AppRootPage from 'app/features/plugins/AppRootPage';
 import config from 'app/core/config';
 import { route, ILocationProvider } from 'angular';
 
@@ -39,6 +20,9 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
   // Routes here are guarded both here and server side for react-container routes or just on the server for angular
   // ones. That means angular ones could be navigated to in case there is a client side link some where.
 
+  const importDashboardPage = () =>
+    import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPage');
+
   $routeProvider
     .when('/', {
       template: '<react-container />',
@@ -47,7 +31,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
       routeInfo: DashboardRouteInfo.Home,
       reloadOnSearch: false,
       resolve: {
-        component: () => DashboardPage,
+        component: importDashboardPage,
       },
     })
     .when('/d/:uid/:slug', {
@@ -56,7 +40,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
       routeInfo: DashboardRouteInfo.Normal,
       reloadOnSearch: false,
       resolve: {
-        component: () => DashboardPage,
+        component: importDashboardPage,
       },
     })
     .when('/d/:uid', {
@@ -65,7 +49,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
       reloadOnSearch: false,
       routeInfo: DashboardRouteInfo.Normal,
       resolve: {
-        component: () => DashboardPage,
+        component: importDashboardPage,
       },
     })
     .when('/dashboard/:type/:slug', {
@@ -74,7 +58,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
       routeInfo: DashboardRouteInfo.Normal,
       reloadOnSearch: false,
       resolve: {
-        component: () => DashboardPage,
+        component: importDashboardPage,
       },
     })
     .when('/dashboard/new', {
@@ -83,7 +67,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
       routeInfo: DashboardRouteInfo.New,
       reloadOnSearch: false,
       resolve: {
-        component: () => DashboardPage,
+        component: importDashboardPage,
       },
     })
     .when('/d-solo/:uid/:slug', {
@@ -92,7 +76,8 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
       routeInfo: DashboardRouteInfo.Normal,
       reloadOnSearch: false,
       resolve: {
-        component: () => SoloPanelPage,
+        component: () =>
+          import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage'),
       },
     })
     .when('/dashboard-solo/:type/:slug', {
@@ -101,7 +86,8 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
       routeInfo: DashboardRouteInfo.Normal,
       reloadOnSearch: false,
       resolve: {
-        component: () => SoloPanelPage,
+        component: () =>
+          import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage'),
       },
     })
     .when('/dashboard/import', {
@@ -112,26 +98,29 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
     .when('/datasources', {
       template: '<react-container />',
       resolve: {
-        component: () => DataSourcesListPage,
+        component: () =>
+          import(/* webpackChunkName: "DataSourcesListPage"*/ 'app/features/datasources/DataSourcesListPage'),
       },
     })
     .when('/datasources/edit/:id/', {
       template: '<react-container />',
       reloadOnSearch: false, // for tabs
       resolve: {
-        component: () => DataSourceSettingsPage,
+        component: () =>
+          import(/* webpackChunkName: "DataSourceSettingsPage"*/ '../features/datasources/settings/DataSourceSettingsPage'),
       },
     })
     .when('/datasources/edit/:id/dashboards', {
       template: '<react-container />',
       resolve: {
-        component: () => DataSourceDashboards,
+        component: () =>
+          import(/* webpackChunkName: "DataSourceDashboards"*/ 'app/features/datasources/DataSourceDashboards'),
       },
     })
     .when('/datasources/new', {
       template: '<react-container />',
       resolve: {
-        component: () => NewDataSourcePage,
+        component: () => import(/* webpackChunkName: "NewDataSourcePage"*/ '../features/datasources/NewDataSourcePage'),
       },
     })
     .when('/dashboards', {
@@ -147,13 +136,13 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
     .when('/dashboards/f/:uid/:slug/permissions', {
       template: '<react-container />',
       resolve: {
-        component: () => FolderPermissions,
+        component: () => import(/* webpackChunkName: "FolderPermissions"*/ 'app/features/folders/FolderPermissions'),
       },
     })
     .when('/dashboards/f/:uid/:slug/settings', {
       template: '<react-container />',
       resolve: {
-        component: () => FolderSettingsPage,
+        component: () => import(/* webpackChunkName: "FolderSettingsPage"*/ 'app/features/folders/FolderSettingsPage'),
       },
     })
     .when('/dashboards/f/:uid/:slug', {
@@ -179,13 +168,13 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
       template: '<react-container />',
       reloadOnSearch: false,
       resolve: {
-        component: () => AppRootPage,
+        component: () => import(/* webpackChunkName: "AppRootPage" */ 'app/features/plugins/AppRootPage'),
       },
     })
     .when('/org', {
       template: '<react-container />',
       resolve: {
-        component: () => OrgDetailsPage,
+        component: () => import(/* webpackChunkName: "OrgDetailsPage" */ '../features/org/OrgDetailsPage'),
       },
     })
     .when('/org/new', {
@@ -195,7 +184,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
     .when('/org/users', {
       template: '<react-container />',
       resolve: {
-        component: () => UsersListPage,
+        component: () => import(/* webpackChunkName: "UsersListPage" */ 'app/features/users/UsersListPage'),
       },
     })
     .when('/org/users/invite', {
@@ -207,14 +196,14 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
       template: '<react-container />',
       resolve: {
         roles: () => ['Editor', 'Admin'],
-        component: () => ApiKeys,
+        component: () => import(/* webpackChunkName: "ApiKeysPage" */ 'app/features/api-keys/ApiKeysPage'),
       },
     })
     .when('/org/teams', {
       template: '<react-container />',
       resolve: {
         roles: () => (config.editorsCanAdmin ? [] : ['Editor', 'Admin']),
-        component: () => TeamList,
+        component: () => import(/* webpackChunkName: "TeamList" */ 'app/features/teams/TeamList'),
       },
     })
     .when('/org/teams/new', {
@@ -226,7 +215,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
       template: '<react-container />',
       resolve: {
         roles: () => (config.editorsCanAdmin ? [] : ['Admin']),
-        component: () => TeamPages,
+        component: () => import(/* webpackChunkName: "TeamPages" */ 'app/features/teams/TeamPages'),
       },
     })
     .when('/profile', {
@@ -237,7 +226,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
     .when('/profile/password', {
       template: '<react-container />',
       resolve: {
-        component: () => ChangePasswordPage,
+        component: () => import(/* webPackChunkName: "ChangePasswordPage" */ 'app/features/profile/ChangePasswordPage'),
       },
     })
     .when('/profile/select-org', {
@@ -281,7 +270,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
     .when('/admin/stats', {
       template: '<react-container />',
       resolve: {
-        component: () => ServerStats,
+        component: () => import(/* webpackChunkName: "ServerStats" */ 'app/features/admin/ServerStats'),
       },
     })
     // LOGIN / SIGNUP
@@ -320,14 +309,14 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
     .when('/plugins', {
       template: '<react-container />',
       resolve: {
-        component: () => PluginListPage,
+        component: () => import(/* webpackChunkName: "PluginListPage" */ 'app/features/plugins/PluginListPage'),
       },
     })
     .when('/plugins/:pluginId/', {
       template: '<react-container />',
       reloadOnSearch: false, // tabs from query parameters
       resolve: {
-        component: () => PluginPage,
+        component: () => import(/* webpackChunkName: "PluginPage" */ '../features/plugins/PluginPage'),
       },
     })
     .when('/plugins/:pluginId/page/:slug', {
@@ -347,7 +336,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
       template: '<react-container />',
       reloadOnSearch: false,
       resolve: {
-        component: () => AlertRuleList,
+        component: () => import(/* webpackChunkName: "AlertRuleList" */ 'app/features/alerting/AlertRuleList'),
       },
     })
     .when('/alerting/notifications', {

+ 3 - 0
public/sass/base/_fonts.scss

@@ -59,6 +59,7 @@
 }
 /* latin */
 @font-face {
+  font-display: swap;
   font-family: 'Roboto';
   font-style: normal;
   font-weight: 400;
@@ -121,6 +122,7 @@
 }
 /* latin */
 @font-face {
+  font-display: swap;
   font-family: 'Roboto';
   font-style: normal;
   font-weight: 500;
@@ -184,6 +186,7 @@
 }
 /* latin */
 @font-face {
+  font-display: swap;
   font-family: 'Roboto';
   font-style: italic;
   font-weight: 400;

+ 6 - 4
public/test/specs/helpers.ts

@@ -1,4 +1,6 @@
-import _ from 'lodash';
+import each from 'lodash/each';
+import template from 'lodash/template';
+
 import config from 'app/core/config';
 import { dateMath } from '@grafana/data';
 import { angularMocks, sinon } from '../lib/common';
@@ -37,7 +39,7 @@ export function ControllerTestContext(this: any) {
       $provide.value('templateSrv', self.templateSrv);
       $provide.value('$element', self.$element);
       $provide.value('$sanitize', self.$sanitize);
-      _.each(mocks, (value: any, key: any) => {
+      each(mocks, (value: any, key: any) => {
         $provide.value(key, value);
       });
     });
@@ -118,7 +120,7 @@ export function ServiceTestContext(this: any) {
 
   this.providePhase = (mocks: any) => {
     return angularMocks.module(($provide: any) => {
-      _.each(mocks, (key: string) => {
+      each(mocks, (key: string) => {
         $provide.value(key, self[key]);
       });
     });
@@ -184,7 +186,7 @@ export function TemplateSrvStub(this: any) {
   this.templateSettings = { interpolate: /\[\[([\s\S]+?)\]\]/g };
   this.data = {};
   this.replace = (text: string) => {
-    return _.template(text, this.templateSettings)(this.data);
+    return template(text, this.templateSettings)(this.data);
   };
   this.init = () => {};
   this.getAdhocFilters = (): any => {

+ 1 - 0
public/views/index-template.html

@@ -11,6 +11,7 @@
 
   <base href="[[.AppSubUrl]]/" />
 
+  <link rel="preload" href="public/fonts/roboto/RxZJdnzeo3R5zSexge8UUVtXRa8TVwTICgirnJhmVJw.woff2" as="font" crossorigin />
   <link rel="icon" type="image/png" href="public/img/fav32.png">
   <link rel="mask-icon" href="public/img/grafana_mask_icon.svg" color="#F05A28">
   <link rel="apple-touch-icon" sizes="180x180" href="public/img/apple-touch-icon.png">

+ 2 - 2
scripts/webpack/dependencies.js

@@ -1,12 +1,12 @@
 'use strict';
 
 const pkg = require('../../package.json');
-const _ = require('lodash');
+const pull = require('lodash/pull');
 
 let dependencies = Object.keys(pkg.dependencies);
 // remove jquery so we can add it first
 // remove rxjs so we can only depend on parts of it in code
-_.pull(dependencies, 'jquery', 'rxjs')
+pull(dependencies, 'jquery', 'rxjs')
 
 // add jquery first
 dependencies.unshift('jquery');

+ 56 - 30
scripts/webpack/webpack.common.js

@@ -1,4 +1,5 @@
 const path = require('path');
+const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
 
 module.exports = {
   target: 'web',
@@ -13,63 +14,88 @@ module.exports = {
   },
   resolve: {
     extensions: ['.ts', '.tsx', '.es6', '.js', '.json', '.svg'],
-    alias: {
-    },
-    modules: [
-      path.resolve('public'),
-      path.resolve('node_modules')
-    ],
+    alias: {},
+    modules: [path.resolve('public'), path.resolve('node_modules')],
   },
   stats: {
     children: false,
-    warningsFilter: /export .* was not found in/
+    warningsFilter: /export .* was not found in/,
+    source: false
   },
   node: {
     fs: 'empty',
   },
   module: {
-    rules: [
-      {
+    rules: [{
         test: require.resolve('jquery'),
-        use: [
-          {
+        use: [{
             loader: 'expose-loader',
-            query: 'jQuery'
+            query: 'jQuery',
           },
           {
             loader: 'expose-loader',
-            query: '$'
-          }
-        ]
+            query: '$',
+          },
+        ],
       },
       {
         test: /\.html$/,
         exclude: /(index|error)\-template\.html/,
-        use: [
-          { loader: 'ngtemplate-loader?relativeTo=' + (path.resolve(__dirname, '../../public')) + '&prefix=public' },
+        use: [{
+            loader: 'ngtemplate-loader?relativeTo=' + path.resolve(__dirname, '../../public') + '&prefix=public',
+          },
           {
             loader: 'html-loader',
             options: {
               attrs: [],
               minimize: true,
               removeComments: false,
-              collapseWhitespace: false
-            }
-          }
-        ]
-      }
-    ]
+              collapseWhitespace: false,
+            },
+          },
+        ],
+      },
+    ],
   },
   // https://webpack.js.org/plugins/split-chunks-plugin/#split-chunks-example-3
   optimization: {
+    moduleIds: 'hashed',
+    runtimeChunk: 'single',
     splitChunks: {
+      chunks: 'all',
+      minChunks: 1,
       cacheGroups: {
-        commons: {
+        moment: {
+          test: /[\\/]node_modules[\\/]moment[\\/].*[jt]sx?$/,
+          chunks: 'initial',
+          priority: 20,
+          enforce: true
+        },
+        angular: {
+          test: /[\\/]node_modules[\\/]angular[\\/].*[jt]sx?$/,
+          chunks: 'initial',
+          priority: 50,
+          enforce: true
+        },
+        vendors: {
           test: /[\\/]node_modules[\\/].*[jt]sx?$/,
-          name: 'vendor',
-          chunks: 'all'
-        }
-      }
-    }
-  }
+          chunks: 'initial',
+          priority: -10,
+          reuseExistingChunk: true,
+          enforce: true
+        },
+        default: {
+          priority: -20,
+          chunks: 'all',
+          test: /.*[jt]sx?$/,
+          reuseExistingChunk: true
+        },
+      },
+    },
+  },
+  plugins: [
+    new ForkTsCheckerWebpackPlugin({
+      checkSyntacticErrors: true,
+    })
+  ],
 };

+ 24 - 18
scripts/webpack/webpack.dev.js

@@ -27,8 +27,7 @@ module.exports = (env = {}) =>
     },
 
     module: {
-      rules: [
-        {
+      rules: [{
           test: /\.tsx?$/,
           enforce: 'pre',
           exclude: /node_modules/,
@@ -37,8 +36,8 @@ module.exports = (env = {}) =>
             options: {
               emitErrors: true,
               typeCheck: false,
-            },
-          },
+            }
+          }
         },
         {
           test: /\.tsx?$/,
@@ -46,48 +45,55 @@ module.exports = (env = {}) =>
           use: {
             loader: 'ts-loader',
             options: {
-              transpileOnly: true,
+              transpileOnly: true
             },
           },
         },
-        require('./sass.rule.js')({ sourceMap: false, preserveUrl: false }),
+        require('./sass.rule.js')({
+          sourceMap: false,
+          preserveUrl: false
+        }),
         {
           test: /\.(png|jpg|gif|ttf|eot|svg|woff(2)?)(\?[a-z0-9=&.]+)?$/,
-          loader: 'file-loader',
+          loader: 'file-loader'
         },
-      ],
+      ]
     },
 
     plugins: [
       new CleanWebpackPlugin(),
-      env.noTsCheck
-        ? new webpack.DefinePlugin({}) // bogus plugin to satisfy webpack API
-        : new ForkTsCheckerWebpackPlugin({
-            checkSyntacticErrors: true,
-          }),
+      env.noTsCheck ?
+      new webpack.DefinePlugin({}) // bogus plugin to satisfy webpack API
+      :
+      new ForkTsCheckerWebpackPlugin({
+        checkSyntacticErrors: true,
+      }),
       new MiniCssExtractPlugin({
-        filename: 'grafana.[name].[hash].css',
+        filename: "grafana.[name].[hash].css"
       }),
       new HtmlWebpackPlugin({
         filename: path.resolve(__dirname, '../../public/views/error.html'),
         template: path.resolve(__dirname, '../../public/views/error-template.html'),
         inject: false,
+        chunksSortMode: 'none',
+        excludeChunks: ['dark', 'light']
       }),
       new HtmlWebpackPlugin({
         filename: path.resolve(__dirname, '../../public/views/index.html'),
         template: path.resolve(__dirname, '../../public/views/index-template.html'),
         inject: 'body',
-        chunks: ['manifest', 'vendor', 'app'],
+        chunksSortMode: 'none',
+        excludeChunks: ['dark', 'light']
       }),
       new webpack.NamedModulesPlugin(),
       new webpack.HotModuleReplacementPlugin(),
       new webpack.DefinePlugin({
         'process.env': {
-          NODE_ENV: JSON.stringify('development'),
-        },
+          'NODE_ENV': JSON.stringify('development')
+        }
       }),
       // new BundleAnalyzerPlugin({
       //   analyzerPort: 8889
       // })
-    ],
+    ]
   });

+ 33 - 27
scripts/webpack/webpack.hot.js

@@ -42,41 +42,44 @@ module.exports = merge(common, {
 
   optimization: {
     removeAvailableModules: false,
+    runtimeChunk: false,
     removeEmptyChunks: false,
-    splitChunks: false,
+    splitChunks: false
   },
 
   module: {
-    rules: [
-      {
+    rules: [{
         test: /\.tsx?$/,
         exclude: /node_modules/,
-        use: [
-          {
-            loader: 'babel-loader',
-            options: {
-              cacheDirectory: true,
-              babelrc: false,
-              plugins: [
-                [require('@rtsao/plugin-proposal-class-properties'), { loose: true }],
-                'angularjs-annotate',
-                '@babel/plugin-syntax-dynamic-import', // needed for `() => import()` in routes.ts
-                'react-hot-loader/babel',
-              ],
-              presets: [
-                [
-                  '@babel/preset-env',
-                  {
-                    targets: { browsers: 'last 3 versions' },
-                    useBuiltIns: 'entry',
+        use: [{
+          loader: 'babel-loader',
+          options: {
+            cacheDirectory: true,
+            babelrc: false,
+            plugins: [
+              [require('@rtsao/plugin-proposal-class-properties'), {
+                loose: true
+              }],
+              'angularjs-annotate',
+              '@babel/plugin-syntax-dynamic-import', // needed for `() => import()` in routes.ts
+              'react-hot-loader/babel',
+            ],
+            presets: [
+              [
+                '@babel/preset-env',
+                {
+                  targets: {
+                    browsers: 'last 3 versions'
                   },
-                ],
-                '@babel/preset-typescript',
-                '@babel/preset-react',
+                  useBuiltIns: 'entry',
+                  modules: false
+                },
               ],
-            },
+              '@babel/preset-typescript',
+              '@babel/preset-react',
+            ],
           },
-        ],
+        }, ],
       },
       {
         test: /\.scss$/,
@@ -86,7 +89,9 @@ module.exports = merge(common, {
           {
             loader: 'postcss-loader',
             options: {
-              config: { path: __dirname + '/postcss.config.js' },
+              config: {
+                path: __dirname + '/postcss.config.js'
+              },
             },
           },
           {
@@ -108,6 +113,7 @@ module.exports = merge(common, {
       template: path.resolve(__dirname, '../../public/views/index-template.html'),
       inject: 'body',
       alwaysWriteToDisk: true,
+      chunksSortMode: 'none'
     }),
     new HtmlWebpackHarddiskPlugin(),
     new webpack.NamedModulesPlugin(),

+ 8 - 4
scripts/webpack/webpack.prod.js

@@ -20,8 +20,7 @@ module.exports = merge(common, {
   },
 
   module: {
-    rules: [
-      {
+    rules: [{
         test: /\.tsx?$/,
         enforce: 'pre',
         exclude: /node_modules/,
@@ -44,11 +43,13 @@ module.exports = merge(common, {
         },
       },
       require('./sass.rule.js')({
-        sourceMap: false, preserveUrl: false
+        sourceMap: false,
+        preserveUrl: false
       })
     ]
   },
   optimization: {
+    nodeEnv: 'production',
     minimizer: [
       new TerserPlugin({
         cache: false,
@@ -70,12 +71,15 @@ module.exports = merge(common, {
       filename: path.resolve(__dirname, '../../public/views/error.html'),
       template: path.resolve(__dirname, '../../public/views/error-template.html'),
       inject: false,
+      excludeChunks: ['dark', 'light'],
+      chunksSortMode: 'none'
     }),
     new HtmlWebpackPlugin({
       filename: path.resolve(__dirname, '../../public/views/index.html'),
       template: path.resolve(__dirname, '../../public/views/index-template.html'),
       inject: 'body',
-      chunks: ['vendor', 'app'],
+      excludeChunks: ['manifest', 'dark', 'light'],
+      chunksSortMode: 'none'
     }),
     function () {
       this.hooks.done.tap('Done', function (stats) {