From 85d6537279414fb49ba4256547e16873c884e2fb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ester=20Mart=C3=AD=20Vilaseca?=
 <ester.martivilaseca@elastic.co>
Date: Mon, 23 Aug 2021 14:29:10 +0200
Subject: [PATCH] [Stack Monitoring] Add initial react app (#109218)

* Add feature toggle

* Add basic react app with router and simple loading page

* Add title hook

* Add loading page with page template and clusters hook

* fix types

* fix tests

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
---
 .../public/application/hooks/use_clusters.ts  | 65 +++++++++++++++++++
 .../public/application/hooks/use_title.ts     | 24 +++++++
 .../monitoring/public/application/index.tsx   | 61 +++++++++++++++++
 .../public/application/pages/loading_page.tsx | 41 ++++++++++++
 .../application/pages/page_template.tsx       | 20 ++++++
 .../monitoring/public/components/index.d.ts   |  8 +++
 x-pack/plugins/monitoring/public/plugin.ts    | 40 +++++++-----
 .../plugins/monitoring/server/config.test.ts  |  1 +
 x-pack/plugins/monitoring/server/config.ts    |  1 +
 9 files changed, 245 insertions(+), 16 deletions(-)
 create mode 100644 x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts
 create mode 100644 x-pack/plugins/monitoring/public/application/hooks/use_title.ts
 create mode 100644 x-pack/plugins/monitoring/public/application/index.tsx
 create mode 100644 x-pack/plugins/monitoring/public/application/pages/loading_page.tsx
 create mode 100644 x-pack/plugins/monitoring/public/application/pages/page_template.tsx
 create mode 100644 x-pack/plugins/monitoring/public/components/index.d.ts

diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts b/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts
new file mode 100644
index 0000000000000..49f6464b2ce3e
--- /dev/null
+++ b/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts
@@ -0,0 +1,65 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { useState, useEffect } from 'react';
+import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
+import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../common/constants';
+
+export function useClusters(codePaths?: string[], fetchAllClusters?: boolean, ccs?: any) {
+  const clusterUuid = fetchAllClusters ? null : '';
+  const { services } = useKibana<{ data: any }>();
+
+  const bounds = services.data?.query.timefilter.timefilter.getBounds();
+  const [min] = useState(bounds.min.toISOString());
+  const [max] = useState(bounds.max.toISOString());
+
+  const [clusters, setClusters] = useState([]);
+  const [loaded, setLoaded] = useState<boolean | null>(false);
+
+  let url = '../api/monitoring/v1/clusters';
+  if (clusterUuid) {
+    url += `/${clusterUuid}`;
+  }
+
+  useEffect(() => {
+    const fetchClusters = async () => {
+      try {
+        const response = await services.http?.fetch(url, {
+          method: 'POST',
+          body: JSON.stringify({
+            ccs,
+            timeRange: {
+              min,
+              max,
+            },
+            codePaths,
+          }),
+        });
+
+        setClusters(formatClusters(response));
+      } catch (err) {
+        // TODO: handle errors
+      } finally {
+        setLoaded(null);
+      }
+    };
+
+    fetchClusters();
+  }, [ccs, services.http, codePaths, url, min, max]);
+
+  return { clusters, loaded };
+}
+
+function formatClusters(clusters: any) {
+  return clusters.map(formatCluster);
+}
+
+function formatCluster(cluster: any) {
+  if (cluster.cluster_uuid === STANDALONE_CLUSTER_CLUSTER_UUID) {
+    cluster.cluster_name = 'Standalone Cluster';
+  }
+  return cluster;
+}
diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_title.ts b/x-pack/plugins/monitoring/public/application/hooks/use_title.ts
new file mode 100644
index 0000000000000..25cc2c5b40dff
--- /dev/null
+++ b/x-pack/plugins/monitoring/public/application/hooks/use_title.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { get } from 'lodash';
+import { i18n } from '@kbn/i18n';
+import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
+
+// TODO: verify that works for all pages
+export function useTitle(cluster: string, suffix: string) {
+  const { services } = useKibana();
+  let clusterName = get(cluster, 'cluster_name');
+  clusterName = clusterName ? `- ${clusterName}` : '';
+  suffix = suffix ? `- ${suffix}` : '';
+
+  services.chrome?.docTitle.change(
+    i18n.translate('xpack.monitoring.stackMonitoringDocTitle', {
+      defaultMessage: 'Stack Monitoring {clusterName} {suffix}',
+      values: { clusterName, suffix },
+    })
+  );
+}
diff --git a/x-pack/plugins/monitoring/public/application/index.tsx b/x-pack/plugins/monitoring/public/application/index.tsx
new file mode 100644
index 0000000000000..a0c9afd73f0ce
--- /dev/null
+++ b/x-pack/plugins/monitoring/public/application/index.tsx
@@ -0,0 +1,61 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { CoreStart, AppMountParameters } from 'kibana/public';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { Route, Switch, Redirect, HashRouter } from 'react-router-dom';
+import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
+import { LoadingPage } from './pages/loading_page';
+import { MonitoringStartPluginDependencies } from '../types';
+
+export const renderApp = (
+  core: CoreStart,
+  plugins: MonitoringStartPluginDependencies,
+  { element }: AppMountParameters
+) => {
+  ReactDOM.render(<MonitoringApp core={core} plugins={plugins} />, element);
+
+  return () => {
+    ReactDOM.unmountComponentAtNode(element);
+  };
+};
+
+const MonitoringApp: React.FC<{
+  core: CoreStart;
+  plugins: MonitoringStartPluginDependencies;
+}> = ({ core, plugins }) => {
+  return (
+    <KibanaContextProvider services={{ ...core, ...plugins }}>
+      <HashRouter>
+        <Switch>
+          <Route path="/loading" component={LoadingPage} />
+          <Route path="/no-data" component={NoData} />
+          <Route path="/home" component={Home} />
+          <Route path="/overview" component={ClusterOverview} />
+          <Redirect
+            to={{
+              pathname: '/loading',
+            }}
+          />
+        </Switch>
+      </HashRouter>
+    </KibanaContextProvider>
+  );
+};
+
+const NoData: React.FC<{}> = () => {
+  return <div>No data page</div>;
+};
+
+const Home: React.FC<{}> = () => {
+  return <div>Home page (Cluster listing)</div>;
+};
+
+const ClusterOverview: React.FC<{}> = () => {
+  return <div>Cluster overview page</div>;
+};
diff --git a/x-pack/plugins/monitoring/public/application/pages/loading_page.tsx b/x-pack/plugins/monitoring/public/application/pages/loading_page.tsx
new file mode 100644
index 0000000000000..4bd09f73ac75a
--- /dev/null
+++ b/x-pack/plugins/monitoring/public/application/pages/loading_page.tsx
@@ -0,0 +1,41 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { Redirect } from 'react-router-dom';
+import { i18n } from '@kbn/i18n';
+import { PageTemplate } from './page_template';
+import { PageLoading } from '../../components';
+import { useClusters } from '../hooks/use_clusters';
+import { CODE_PATH_ELASTICSEARCH } from '../../../common/constants';
+
+const CODE_PATHS = [CODE_PATH_ELASTICSEARCH];
+
+export const LoadingPage = () => {
+  const { clusters, loaded } = useClusters(CODE_PATHS, true);
+  const title = i18n.translate('xpack.monitoring.loading.pageTitle', {
+    defaultMessage: 'Loading',
+  });
+
+  return (
+    <PageTemplate title={title}>
+      {loaded === false ? <PageLoading /> : renderRedirections(clusters)}
+    </PageTemplate>
+  );
+};
+
+const renderRedirections = (clusters: any) => {
+  if (!clusters || !clusters.length) {
+    return <Redirect to="/no-data" />;
+  }
+  if (clusters.length === 1) {
+    // Bypass the cluster listing if there is just 1 cluster
+    return <Redirect to="/overview" />;
+  }
+
+  return <Redirect to="/home" />;
+};
diff --git a/x-pack/plugins/monitoring/public/application/pages/page_template.tsx b/x-pack/plugins/monitoring/public/application/pages/page_template.tsx
new file mode 100644
index 0000000000000..fb766af6c8cbe
--- /dev/null
+++ b/x-pack/plugins/monitoring/public/application/pages/page_template.tsx
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { useTitle } from '../hooks/use_title';
+
+interface PageTemplateProps {
+  title: string;
+  children: React.ReactNode;
+}
+
+export const PageTemplate = ({ title, children }: PageTemplateProps) => {
+  useTitle('', title);
+
+  return <div>{children}</div>;
+};
diff --git a/x-pack/plugins/monitoring/public/components/index.d.ts b/x-pack/plugins/monitoring/public/components/index.d.ts
new file mode 100644
index 0000000000000..d027298c81c4c
--- /dev/null
+++ b/x-pack/plugins/monitoring/public/components/index.d.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export const PageLoading: FunctionComponent<Props>;
diff --git a/x-pack/plugins/monitoring/public/plugin.ts b/x-pack/plugins/monitoring/public/plugin.ts
index 710b453e7f21e..df0496d438013 100644
--- a/x-pack/plugins/monitoring/public/plugin.ts
+++ b/x-pack/plugins/monitoring/public/plugin.ts
@@ -94,6 +94,7 @@ export class MonitoringPlugin
       mount: async (params: AppMountParameters) => {
         const [coreStart, pluginsStart] = await core.getStartServices();
         const { AngularApp } = await import('./angular');
+        const externalConfig = this.getExternalConfig();
         const deps: MonitoringStartPluginDependencies = {
           navigation: pluginsStart.navigation,
           kibanaLegacy: pluginsStart.kibanaLegacy,
@@ -102,27 +103,33 @@ export class MonitoringPlugin
           data: pluginsStart.data,
           isCloud: Boolean(plugins.cloud?.isCloudEnabled),
           pluginInitializerContext: this.initializerContext,
-          externalConfig: this.getExternalConfig(),
+          externalConfig,
           triggersActionsUi: pluginsStart.triggersActionsUi,
           usageCollection: plugins.usageCollection,
           appMountParameters: params,
         };
 
-        const monitoringApp = new AngularApp(deps);
-        const removeHistoryListener = params.history.listen((location) => {
-          if (location.pathname === '' && location.hash === '') {
-            monitoringApp.applyScope();
-          }
-        });
-
-        const removeHashChange = this.setInitialTimefilter(deps);
-        return () => {
-          if (removeHashChange) {
-            removeHashChange();
-          }
-          removeHistoryListener();
-          monitoringApp.destroy();
-        };
+        const config = Object.fromEntries(externalConfig);
+        if (config.renderReactApp) {
+          const { renderApp } = await import('./application');
+          return renderApp(coreStart, pluginsStart, params);
+        } else {
+          const monitoringApp = new AngularApp(deps);
+          const removeHistoryListener = params.history.listen((location) => {
+            if (location.pathname === '' && location.hash === '') {
+              monitoringApp.applyScope();
+            }
+          });
+
+          const removeHashChange = this.setInitialTimefilter(deps);
+          return () => {
+            if (removeHashChange) {
+              removeHashChange();
+            }
+            removeHistoryListener();
+            monitoringApp.destroy();
+          };
+        }
       },
     };
 
@@ -163,6 +170,7 @@ export class MonitoringPlugin
       ['showLicenseExpiration', monitoring.ui.show_license_expiration],
       ['showCgroupMetricsElasticsearch', monitoring.ui.container.elasticsearch.enabled],
       ['showCgroupMetricsLogstash', monitoring.ui.container.logstash.enabled],
+      ['renderReactApp', monitoring.ui.render_react_app],
     ];
   }
 
diff --git a/x-pack/plugins/monitoring/server/config.test.ts b/x-pack/plugins/monitoring/server/config.test.ts
index f39e5a0703d22..90c6e68314b71 100644
--- a/x-pack/plugins/monitoring/server/config.test.ts
+++ b/x-pack/plugins/monitoring/server/config.test.ts
@@ -106,6 +106,7 @@ describe('config schema', () => {
             "index": "metricbeat-*",
           },
           "min_interval_seconds": 10,
+          "render_react_app": false,
           "show_license_expiration": true,
         },
       }
diff --git a/x-pack/plugins/monitoring/server/config.ts b/x-pack/plugins/monitoring/server/config.ts
index 98fd02b03539c..5c2bdc1424f93 100644
--- a/x-pack/plugins/monitoring/server/config.ts
+++ b/x-pack/plugins/monitoring/server/config.ts
@@ -51,6 +51,7 @@ export const configSchema = schema.object({
     }),
     min_interval_seconds: schema.number({ defaultValue: 10 }),
     show_license_expiration: schema.boolean({ defaultValue: true }),
+    render_react_app: schema.boolean({ defaultValue: false }),
   }),
   kibana: schema.object({
     collection: schema.object({