Просмотр исходного кода

Merge pull request #1574 from weseek/imprv/admin-search

Imprv/admin search
Yuki Takei 6 лет назад
Родитель
Сommit
cfb2445c42
34 измененных файлов с 1021 добавлено и 284 удалено
  1. 2 0
      CHANGES.md
  2. 1 1
      package.json
  3. 35 4
      resource/locales/en-US/translation.json
  4. 35 4
      resource/locales/ja/translation.json
  5. 1 1
      src/client/js/app.jsx
  6. 210 0
      src/client/js/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx
  7. 49 0
      src/client/js/components/Admin/ElasticsearchManagement/NormalizeIndicesControls.jsx
  8. 116 0
      src/client/js/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  9. 48 0
      src/client/js/components/Admin/ElasticsearchManagement/ReconnectControls.jsx
  10. 155 0
      src/client/js/components/Admin/ElasticsearchManagement/StatusTable.jsx
  11. 2 2
      src/client/js/components/Admin/FullTextSearchManagement.jsx
  12. 0 133
      src/client/js/components/Admin/FullTextSearchManagement/RebuildIndex.jsx
  13. 19 5
      src/client/js/components/HeaderSearchBox.jsx
  14. 23 4
      src/client/js/components/SearchForm.jsx
  15. 11 7
      src/client/js/components/SearchTypeahead.jsx
  16. 15 13
      src/client/styles/scss/_override-rbt.scss
  17. 5 1
      src/client/styles/scss/_search.scss
  18. 3 2
      src/server/crowi/index.js
  19. 1 0
      src/server/models/config.js
  20. 3 17
      src/server/routes/admin.js
  21. 2 0
      src/server/routes/apiv3/index.js
  22. 131 0
      src/server/routes/apiv3/search.js
  23. 0 1
      src/server/routes/index.js
  24. 1 1
      src/server/routes/installer.js
  25. 85 62
      src/server/service/search-delegator/elasticsearch.js
  26. 1 1
      src/server/service/search-delegator/searchbox.js
  27. 41 6
      src/server/service/search.js
  28. 3 5
      src/server/util/swigFunctions.js
  29. 1 1
      src/server/views/layout-crowi/page_list.html
  30. 2 2
      src/server/views/layout/layout.html
  31. 1 1
      src/server/views/modal/create_page.html
  32. 1 1
      src/server/views/modal/duplicate.html
  33. 1 1
      src/server/views/modal/rename.html
  34. 17 8
      yarn.lock

+ 2 - 0
CHANGES.md

@@ -3,6 +3,8 @@
 ## v3.6.5-RC
 ## v3.6.5-RC
 
 
 * Impromvement: Add `checkMiddlewaresStrictly` query option to Healthcheck API
 * Impromvement: Add `checkMiddlewaresStrictly` query option to Healthcheck API
+* Support: Upgrade libs
+    * react-bootstrap-typeahead
 
 
 ## v3.6.4
 ## v3.6.4
 
 

+ 1 - 1
package.json

@@ -215,7 +215,7 @@
     "prettier": "^1.19.1",
     "prettier": "^1.19.1",
     "react": "^16.8.3",
     "react": "^16.8.3",
     "react-bootstrap": "^0.32.1",
     "react-bootstrap": "^0.32.1",
-    "react-bootstrap-typeahead": "^3.4.2",
+    "react-bootstrap-typeahead": "^3.4.7",
     "react-codemirror2": "^6.0.0",
     "react-codemirror2": "^6.0.0",
     "react-copy-to-clipboard": "^5.0.1",
     "react-copy-to-clipboard": "^5.0.1",
     "react-dom": "^16.8.3",
     "react-dom": "^16.8.3",

+ 35 - 4
resource/locales/en-US/translation.json

@@ -530,9 +530,40 @@
   },
   },
   "full_text_search_management": {
   "full_text_search_management": {
     "elasticsearch_management": "Elasticsearch Management",
     "elasticsearch_management": "Elasticsearch Management",
-    "build_button": "Rebuild Index",
-    "rebuild_description_1": "Force rebuild index.",
-    "rebuild_description_2": "Click 'Build Now' to delete and create mapping file and add all pages.",
-    "rebuild_description_3": "This may take a while."
+    "connection_status": "Connection Status",
+    "connection_status_label_connected": "CONNECTED",
+    "connection_status_label_disconnected": "DISCONNECTED",
+    "indices_status": "Indices Status",
+    "indices_status_label_normalized": "NORMALIZED",
+    "indices_status_label_unnormalized": "REBUILDING or BROKEN",
+    "indices_summary": "Indices Summary",
+    "reconnect": "Reconnect",
+    "reconnect_button": "Try to Reconnect",
+    "reconnect_description": "Click the button to try to reconnect to Elasticsearch.",
+    "normalize": "Normalize",
+    "normalize_button": "Normalize Indices",
+    "normalize_description": "Click the button to repair broken indices.",
+    "rebuild": "Rebuild",
+    "rebuild_button": "Rebuild Index",
+    "rebuild_description_1": "Click the button to rebuild index and add all page datas.",
+    "rebuild_description_2": "This may take a while."
+  },
+  "export_management": {
+    "exporting_collection_list": "Exporting Collection List",
+    "exported_data_list": "Exported Archive Data List",
+    "export_collections": "Export Collections",
+    "check_all": "Check All",
+    "uncheck_all": "Uncheck All",
+    "desc_password_seed": "DO NOT FORGET to set current <code>PASSWORD_SEED</code> to your new GROWI system when restoring user data, or users will NOT be able to login with their password.<br><br><strong>HINT:</strong><br>The current <code>PASSWORD_SEED</code> will be stored in <code>meta.json</code> in exported ZIP.",
+    "create_new_archive_data": "Create New Archive Data",
+    "export": "Export",
+    "cancel": "Cancel",
+    "file": "File",
+    "growi_version": "Growi Version",
+    "collections": "Collections",
+    "exported_at": "Exported At",
+    "export_menu": "Export Menu",
+    "download": "Download",
+    "delete": "Delete"
   }
   }
 }
 }

+ 35 - 4
resource/locales/ja/translation.json

@@ -513,9 +513,40 @@
   },
   },
   "full_text_search_management": {
   "full_text_search_management": {
     "elasticsearch_management": "Elasticsearch 管理",
     "elasticsearch_management": "Elasticsearch 管理",
-    "build_button": "インデックスのリビルド",
-    "rebuild_description_1": "Build Now ボタンを押すと全てのページのインデックスを削除し、作り直します。",
-    "rebuild_description_2": "この作業には数秒かかります。",
-    "rebuild_description_3": ""
+    "connection_status": "接続の状態",
+    "connection_status_label_connected": "接続されています",
+    "connection_status_label_disconnected": "切断されています",
+    "indices_status": "インデックスの状態",
+    "indices_status_label_normalized": "正規化されています",
+    "indices_status_label_unnormalized": "リビルド中 または 破損しています",
+    "indices_summary": "インデックスのサマリ",
+    "reconnect": "再接続",
+    "reconnect_button": "再接続の試行",
+    "reconnect_description": "Elasticsearch への再接続を試みます。",
+    "normalize": "正規化",
+    "normalize_button": "インデックスの正規化",
+    "normalize_description": "破損したインデックスを修復します。",
+    "rebuild": "リビルド",
+    "rebuild_button": "インデックスのリビルド",
+    "rebuild_description_1": "全てのページのインデックスを削除し、作り直します。",
+    "rebuild_description_2": "この作業には数秒かかります。"
+  },
+  "export_management": {
+    "exporting_collection_list": "エクスポート中のコレクション",
+    "exported_data_list": "エクスポートされたアーカイブリスト",
+    "export_collections": "コレクションのエクスポート",
+    "check_all": "全てにチェックを付ける",
+    "uncheck_all": "全てからチェックを外す",
+    "desc_password_seed": "ユーザーデータをバックアップ/リストアする場合、現在の <code>PASSWORD_SEED</code> を新しい GROWI システムにセットすることを忘れないでください。さもなくば、ユーザーがパスワードでログインできなくなります。<br><br><strong>ヒント:</strong><br>現在の <code>PASSWORD_SEED</code> は、エクスポートされる ZIP 中の <code>meta.json</code> に保存されます。",
+    "create_new_archive_data": "アーカイブデータの新規作成",
+    "export": "エクスポート",
+    "cancel": "キャンセル",
+    "file": "ファイル名",
+    "growi_version": "Growi バージョン",
+    "collections": "コレクション",
+    "exported_at": "エクスポートされた時間",
+    "export_menu": "エクスポートメニュー",
+    "download": "ダウンロード",
+    "delete": "削除"
   }
   }
 }
 }

+ 1 - 1
src/client/js/app.jsx

@@ -94,7 +94,7 @@ const i18n = appContainer.i18n;
  *  value: React Element
  *  value: React Element
  */
  */
 let componentMappings = {
 let componentMappings = {
-  'search-top': <HeaderSearchBox crowi={appContainer} />,
+  'search-top': <HeaderSearchBox />,
   'search-sidebar': <HeaderSearchBox crowi={appContainer} />,
   'search-sidebar': <HeaderSearchBox crowi={appContainer} />,
   'search-page': <SearchPage crowi={appContainer} />,
   'search-page': <SearchPage crowi={appContainer} />,
 
 

+ 210 - 0
src/client/js/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx

@@ -0,0 +1,210 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import WebsocketContainer from '../../../services/WebsocketContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import StatusTable from './StatusTable';
+import ReconnectControls from './ReconnectControls';
+import NormalizeIndicesControls from './NormalizeIndicesControls';
+import RebuildIndexControls from './RebuildIndexControls';
+
+class ElasticsearchManagement extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isConnected: null,
+      isRebuildingProcessing: false,
+      isRebuildingCompleted: false,
+
+      isNormalized: null,
+      indicesData: null,
+      aliasesData: null,
+    };
+
+    this.reconnect = this.reconnect.bind(this);
+    this.normalizeIndices = this.normalizeIndices.bind(this);
+    this.rebuildIndices = this.rebuildIndices.bind(this);
+  }
+
+  async componentWillMount() {
+    this.retrieveIndicesStatus();
+  }
+
+  componentDidMount() {
+    this.initWebSockets();
+  }
+
+  initWebSockets() {
+    const socket = this.props.websocketContainer.getWebSocket();
+
+    socket.on('admin:addPageProgress', (data) => {
+      this.setState({
+        isRebuildingProcessing: true,
+      });
+    });
+
+    socket.on('admin:finishAddPage', (data) => {
+      this.setState({
+        isRebuildingProcessing: false,
+        isRebuildingCompleted: true,
+      });
+    });
+
+    socket.on('admin:rebuildingFailed', (data) => {
+      toastError(new Error(data.error), 'Rebuilding Index has failed.');
+    });
+  }
+
+  async retrieveIndicesStatus() {
+    const { appContainer } = this.props;
+
+    try {
+      const { info } = await appContainer.apiv3Get('/search/indices');
+
+      this.setState({
+        isConnected: true,
+
+        indicesData: info.indices,
+        aliasesData: info.aliases,
+        isNormalized: info.isNormalized,
+      });
+    }
+    catch (e) {
+      this.setState({ isConnected: false });
+      toastError(e);
+    }
+  }
+
+  async reconnect() {
+    const { appContainer } = this.props;
+
+    try {
+      await appContainer.apiv3Post('/search/connection');
+      toastSuccess('Reconnecting to Elasticsearch has succeeded');
+    }
+    catch (e) {
+      toastError(e);
+      return;
+    }
+
+    await this.retrieveIndicesStatus();
+  }
+
+  async normalizeIndices() {
+    const { appContainer } = this.props;
+
+    try {
+      await appContainer.apiv3Put('/search/indices', { operation: 'normalize' });
+    }
+    catch (e) {
+      toastError(e);
+    }
+
+    await this.retrieveIndicesStatus();
+
+    toastSuccess('Normalizing has succeeded');
+  }
+
+  async rebuildIndices() {
+    const { appContainer } = this.props;
+
+    this.setState({ isRebuildingProcessing: true });
+
+    try {
+      await appContainer.apiv3Put('/search/indices', { operation: 'rebuild' });
+      toastSuccess('Rebuilding is requested');
+    }
+    catch (e) {
+      toastError(e);
+    }
+
+    await this.retrieveIndicesStatus();
+  }
+
+  render() {
+    const { t } = this.props;
+    const {
+      isConnected, isRebuildingProcessing, isRebuildingCompleted,
+      isNormalized, indicesData, aliasesData,
+    } = this.state;
+
+    return (
+      <>
+        <div className="row">
+          <div className="col-xs-12">
+            <StatusTable
+              isConnected={isConnected}
+              isNormalized={isNormalized}
+              indicesData={indicesData}
+              aliasesData={aliasesData}
+            />
+          </div>
+        </div>
+
+        <hr />
+
+        {/* Controls */}
+        <div className="row">
+          <label className="col-xs-3 control-label">{ t('full_text_search_management.reconnect') }</label>
+          <div className="col-xs-6">
+            <ReconnectControls
+              isConnected={isConnected}
+              onReconnectingRequested={this.reconnect}
+            />
+          </div>
+        </div>
+
+        <hr />
+
+        <div className="row">
+          <label className="col-xs-3 control-label">{ t('full_text_search_management.normalize') }</label>
+          <div className="col-xs-6">
+            <NormalizeIndicesControls
+              isRebuildingProcessing={isRebuildingProcessing}
+              isRebuildingCompleted={isRebuildingCompleted}
+              isNormalized={isNormalized}
+              onNormalizingRequested={this.normalizeIndices}
+            />
+          </div>
+        </div>
+
+        <hr />
+
+        <div className="row">
+          <label className="col-xs-3 control-label">{ t('full_text_search_management.rebuild') }</label>
+          <div className="col-xs-6">
+            <RebuildIndexControls
+              isRebuildingProcessing={isRebuildingProcessing}
+              isRebuildingCompleted={isRebuildingCompleted}
+              isNormalized={isNormalized}
+              onRebuildingRequested={this.rebuildIndices}
+            />
+          </div>
+        </div>
+
+      </>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const ElasticsearchManagementWrapper = (props) => {
+  return createSubscribedElement(ElasticsearchManagement, props, [AppContainer, WebsocketContainer]);
+};
+
+ElasticsearchManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
+};
+
+export default withTranslation()(ElasticsearchManagementWrapper);

+ 49 - 0
src/client/js/components/Admin/ElasticsearchManagement/NormalizeIndicesControls.jsx

@@ -0,0 +1,49 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+
+class NormalizeIndicesControls extends React.PureComponent {
+
+  render() {
+    const { t, isNormalized, isRebuildingProcessing } = this.props;
+
+    const isEnabled = (isNormalized != null) && !isNormalized && !isRebuildingProcessing;
+
+    return (
+      <>
+        <button
+          type="submit"
+          className={`btn btn-outline ${isEnabled ? 'btn-info' : 'btn-default'}`}
+          onClick={() => { this.props.onNormalizingRequested() }}
+          disabled={!isEnabled}
+        >
+          { t('full_text_search_management.normalize_button') }
+        </button>
+
+        <p className="help-block">
+          { t('full_text_search_management.normalize_description') }<br />
+        </p>
+      </>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const NormalizeIndicesControlsWrapper = (props) => {
+  return createSubscribedElement(NormalizeIndicesControls, props, []);
+};
+
+NormalizeIndicesControls.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  isRebuildingProcessing: PropTypes.bool.isRequired,
+  onNormalizingRequested: PropTypes.func.isRequired,
+  isNormalized: PropTypes.bool,
+};
+
+export default withTranslation()(NormalizeIndicesControlsWrapper);

+ 116 - 0
src/client/js/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -0,0 +1,116 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import WebsocketContainer from '../../../services/WebsocketContainer';
+
+import ProgressBar from '../Common/ProgressBar';
+
+class RebuildIndexControls extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      total: 0,
+      current: 0,
+      skip: 0,
+    };
+  }
+
+  componentDidMount() {
+    this.initWebSockets();
+  }
+
+  initWebSockets() {
+    const socket = this.props.websocketContainer.getWebSocket();
+
+    socket.on('admin:addPageProgress', (data) => {
+      this.setState({
+        ...data,
+      });
+    });
+
+    socket.on('admin:finishAddPage', (data) => {
+      this.setState({
+        ...data,
+      });
+    });
+
+  }
+
+  renderProgressBar() {
+    const {
+      isRebuildingProcessing, isRebuildingCompleted,
+    } = this.props;
+    const {
+      total, current, skip,
+    } = this.state;
+    const showProgressBar = isRebuildingProcessing || isRebuildingCompleted;
+
+    if (!showProgressBar) {
+      return null;
+    }
+
+    const header = isRebuildingCompleted ? 'Completed' : `Processing.. (${skip} skips)`;
+
+    return (
+      <ProgressBar
+        header={header}
+        currentCount={current}
+        totalCount={total}
+      />
+    );
+  }
+
+  render() {
+    const { t, isNormalized, isRebuildingProcessing } = this.props;
+
+    const isEnabled = isNormalized && !isRebuildingProcessing;
+
+    return (
+      <>
+        { this.renderProgressBar() }
+
+        <button
+          type="submit"
+          className="btn btn-inverse"
+          onClick={() => { this.props.onRebuildingRequested() }}
+          disabled={!isEnabled}
+        >
+          { t('full_text_search_management.rebuild_button') }
+        </button>
+
+        <p className="help-block">
+          { t('full_text_search_management.rebuild_description_1') }<br />
+          { t('full_text_search_management.rebuild_description_2') }<br />
+        </p>
+      </>
+    );
+  }
+
+}
+
+
+/**
+ * Wrapper component for using unstated
+ */
+const RebuildIndexControlsWrapper = (props) => {
+  return createSubscribedElement(RebuildIndexControls, props, [AppContainer, WebsocketContainer]);
+};
+
+RebuildIndexControls.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
+
+  isRebuildingProcessing: PropTypes.bool.isRequired,
+  isRebuildingCompleted: PropTypes.bool.isRequired,
+
+  isNormalized: PropTypes.bool,
+  onRebuildingRequested: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(RebuildIndexControlsWrapper);

+ 48 - 0
src/client/js/components/Admin/ElasticsearchManagement/ReconnectControls.jsx

@@ -0,0 +1,48 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+
+class ReconnectControls extends React.PureComponent {
+
+  render() {
+    const { t, isConnected } = this.props;
+
+    const isEnabled = (isConnected != null) && !isConnected;
+
+    return (
+      <>
+        <button
+          type="submit"
+          className={`btn btn-outline ${isEnabled ? 'btn-success' : 'btn-default'}`}
+          onClick={() => { this.props.onReconnectingRequested() }}
+          disabled={!isEnabled}
+        >
+          { t('full_text_search_management.reconnect_button') }
+        </button>
+
+        <p className="help-block">
+          { t('full_text_search_management.reconnect_description') }<br />
+        </p>
+      </>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const ReconnectControlsWrapper = (props) => {
+  return createSubscribedElement(ReconnectControls, props, []);
+};
+
+ReconnectControls.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  isConnected: PropTypes.bool,
+  onReconnectingRequested: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(ReconnectControlsWrapper);

+ 155 - 0
src/client/js/components/Admin/ElasticsearchManagement/StatusTable.jsx

@@ -0,0 +1,155 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+
+class StatusTable extends React.PureComponent {
+
+  renderIndexInfoPanel(indexName, body = {}, aliases = []) {
+    const collapseId = `collapse-${indexName}`;
+
+    const aliasLabels = aliases.map((aliasName) => {
+      return (
+        <span key={`label-${indexName}-${aliasName}`} className="label label-primary mr-2">
+          <i className="icon-tag"></i> {aliasName}
+        </span>
+      );
+    });
+
+    return (
+      <div className="panel panel-default">
+        <div className="panel-heading" role="tab">
+          <h4 className="panel-title">
+            <a role="button" data-toggle="collapse" data-parent="#accordion" href={`#${collapseId}`} aria-expanded="true" aria-controls={collapseId}>
+              <i className="fa fa-fw fa-database"></i> {indexName}
+            </a>
+            <span className="ml-3">{aliasLabels}</span>
+          </h4>
+        </div>
+        <div id={collapseId} className="panel-collapse collapse" role="tabpanel">
+          <div className="panel-body">
+            <pre>
+              {JSON.stringify(body, null, 2)}
+            </pre>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  renderIndexInfoPanels() {
+    const {
+      indicesData,
+      aliasesData,
+    } = this.props;
+
+    // data is null
+    if (indicesData == null) {
+      return null;
+    }
+
+    /*
+      "indices": {
+        "growi": {
+          ...
+        }
+      },
+    */
+    const indexNameToDataMap = {};
+    for (const [indexName, indexData] of Object.entries(indicesData)) {
+      indexNameToDataMap[indexName] = indexData;
+    }
+
+    // no indices
+    if (indexNameToDataMap.length === 0) {
+      return null;
+    }
+
+    /*
+      "aliases": {
+        "growi": {
+          "aliases": {
+            "growi-alias": {}
+          }
+        }
+      },
+    */
+    const indexNameToAliasMap = {};
+    for (const [indexName, aliasData] of Object.entries(aliasesData)) {
+      indexNameToAliasMap[indexName] = Object.keys(aliasData.aliases);
+    }
+
+    return (
+      <div className="row">
+        { Object.keys(indexNameToDataMap).map((indexName) => {
+          return (
+            <div key={`col-${indexName}`} className="col-xs-6">
+              { this.renderIndexInfoPanel(indexName, indexNameToDataMap[indexName], indexNameToAliasMap[indexName]) }
+            </div>
+          );
+        }) }
+      </div>
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+    const { isConnected, isNormalized } = this.props;
+
+
+    let connectionStatusLabel = <span className="label label-default">――</span>;
+    if (isConnected != null) {
+      connectionStatusLabel = isConnected
+        ? <span className="label label-success">{ t('full_text_search_management.connection_status_label_connected') }</span>
+        : <span className="label label-danger">{ t('full_text_search_management.connection_status_label_disconnected') }</span>;
+    }
+
+    let indicesStatusLabel = <span className="label label-default">――</span>;
+    if (isNormalized != null) {
+      indicesStatusLabel = isNormalized
+        ? <span className="label label-info">{ t('full_text_search_management.indices_status_label_normalized') }</span>
+        : <span className="label label-warning">{ t('full_text_search_management.indices_status_label_unnormalized') }</span>;
+    }
+
+    return (
+      <table className="table table-bordered">
+        <tbody>
+          <tr>
+            <th>{ t('full_text_search_management.connection_status') }</th>
+            <td>{connectionStatusLabel}</td>
+          </tr>
+          <tr>
+            <th>{ t('full_text_search_management.indices_status') }</th>
+            <td>{indicesStatusLabel}</td>
+          </tr>
+          <tr>
+            <th className="col-sm-4">{ t('full_text_search_management.indices_summary') }</th>
+            <td className="p-4">
+              { this.renderIndexInfoPanels() }
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const StatusTableWrapper = (props) => {
+  return createSubscribedElement(StatusTable, props, []);
+};
+
+StatusTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  isConnected: PropTypes.bool,
+  isNormalized: PropTypes.bool,
+  indicesData: PropTypes.object,
+  aliasesData: PropTypes.object,
+};
+
+export default withTranslation()(StatusTableWrapper);

+ 2 - 2
src/client/js/components/Admin/FullTextSearchManagement.jsx

@@ -5,7 +5,7 @@ import { withTranslation } from 'react-i18next';
 import { createSubscribedElement } from '../UnstatedUtils';
 import { createSubscribedElement } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
 import AppContainer from '../../services/AppContainer';
 
 
-import RebuildIndex from './FullTextSearchManagement/RebuildIndex';
+import ElasticsearchManagement from './ElasticsearchManagement/ElasticsearchManagement';
 
 
 
 
 class FullTextSearchManagement extends React.Component {
 class FullTextSearchManagement extends React.Component {
@@ -16,7 +16,7 @@ class FullTextSearchManagement extends React.Component {
     return (
     return (
       <Fragment>
       <Fragment>
         <h2> { t('full_text_search_management.elasticsearch_management') } </h2>
         <h2> { t('full_text_search_management.elasticsearch_management') } </h2>
-        <RebuildIndex />
+        <ElasticsearchManagement />
       </Fragment>
       </Fragment>
     );
     );
   }
   }

+ 0 - 133
src/client/js/components/Admin/FullTextSearchManagement/RebuildIndex.jsx

@@ -1,133 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { createSubscribedElement } from '../../UnstatedUtils';
-import AppContainer from '../../../services/AppContainer';
-import WebsocketContainer from '../../../services/WebsocketContainer';
-import { toastSuccess, toastError } from '../../../util/apiNotification';
-
-import ProgressBar from '../Common/ProgressBar';
-
-class RebuildIndex extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isProcessing: false,
-      isCompleted: false,
-
-      total: 0,
-      current: 0,
-      skip: 0,
-    };
-
-    this.buildIndex = this.buildIndex.bind(this);
-  }
-
-  componentDidMount() {
-    const socket = this.props.websocketContainer.getWebSocket();
-
-    socket.on('admin:addPageProgress', (data) => {
-      this.setState({
-        isProcessing: true,
-        ...data,
-      });
-    });
-
-    socket.on('admin:finishAddPage', (data) => {
-      this.setState({
-        isProcessing: false,
-        isCompleted: true,
-        ...data,
-      });
-    });
-  }
-
-  async buildIndex() {
-
-    const { appContainer } = this.props;
-    const pageId = this.pageId;
-
-    try {
-      const res = await appContainer.apiPost('/admin/search/build', { page_id: pageId });
-      if (!res.ok) {
-        throw new Error(res.message);
-      }
-
-      this.setState({ isProcessing: true });
-      toastSuccess('Rebuilding is requested');
-    }
-    catch (e) {
-      toastError(e);
-    }
-  }
-
-  renderProgressBar() {
-    const {
-      total, current, skip, isProcessing, isCompleted,
-    } = this.state;
-    const showProgressBar = isProcessing || isCompleted;
-
-    if (!showProgressBar) {
-      return null;
-    }
-
-    const header = isCompleted ? 'Completed' : `Processing.. (${skip} skips)`;
-
-    return (
-      <ProgressBar
-        header={header}
-        currentCount={current}
-        totalCount={total}
-      />
-    );
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <>
-        <div className="row">
-          <div className="col-xs-3 control-label"></div>
-          <div className="col-xs-9">
-            { this.renderProgressBar() }
-
-            <button
-              type="submit"
-              className="btn btn-inverse"
-              onClick={this.buildIndex}
-              disabled={this.state.isProcessing}
-            >
-              { t('full_text_search_management.build_button') }
-            </button>
-
-            <p className="help-block">
-              { t('full_text_search_management.rebuild_description_1') }<br />
-              { t('full_text_search_management.rebuild_description_2') }<br />
-              { t('full_text_search_management.rebuild_description_3') }<br />
-            </p>
-          </div>
-        </div>
-      </>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const RebuildIndexWrapper = (props) => {
-  return createSubscribedElement(RebuildIndex, props, [AppContainer, WebsocketContainer]);
-};
-
-RebuildIndex.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
-};
-
-export default withTranslation()(RebuildIndexWrapper);

+ 19 - 5
src/client/js/components/HeaderSearchBox.jsx

@@ -8,6 +8,9 @@ import DropdownButton from 'react-bootstrap/es/DropdownButton';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 import InputGroup from 'react-bootstrap/es/InputGroup';
 import InputGroup from 'react-bootstrap/es/InputGroup';
 
 
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+
 import SearchForm from './SearchForm';
 import SearchForm from './SearchForm';
 
 
 
 
@@ -60,13 +63,16 @@ class HeaderSearchBox extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const t = this.props.t;
+    const { t, appContainer } = this.props;
     const scopeLabel = this.state.isScopeChildren
     const scopeLabel = this.state.isScopeChildren
       ? t('header_search_box.label.This tree')
       ? t('header_search_box.label.This tree')
       : 'All pages';
       : 'All pages';
 
 
+    const config = appContainer.getConfig();
+    const isReachable = config.isSearchServiceReachable;
+
     return (
     return (
-      <FormGroup>
+      <FormGroup className={isReachable ? '' : 'has-error'}>
         <InputGroup>
         <InputGroup>
           <InputGroup.Button className="btn-group-dropdown-scope">
           <InputGroup.Button className="btn-group-dropdown-scope">
             <DropdownButton id="dbScope" title={scopeLabel}>
             <DropdownButton id="dbScope" title={scopeLabel}>
@@ -76,7 +82,7 @@ class HeaderSearchBox extends React.Component {
           </InputGroup.Button>
           </InputGroup.Button>
           <SearchForm
           <SearchForm
             t={this.props.t}
             t={this.props.t}
-            crowi={this.props.crowi}
+            crowi={this.props.appContainer}
             onInputChange={this.onInputChange}
             onInputChange={this.onInputChange}
             onSubmit={this.search}
             onSubmit={this.search}
             placeholder="Search ..."
             placeholder="Search ..."
@@ -93,9 +99,17 @@ class HeaderSearchBox extends React.Component {
 
 
 }
 }
 
 
+
+/**
+ * Wrapper component for using unstated
+ */
+const HeaderSearchBoxWrapper = (props) => {
+  return createSubscribedElement(HeaderSearchBox, props, [AppContainer]);
+};
+
 HeaderSearchBox.propTypes = {
 HeaderSearchBox.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
 };
 
 
-export default withTranslation()(HeaderSearchBox);
+export default withTranslation()(HeaderSearchBoxWrapper);

+ 23 - 4
src/client/js/components/SearchForm.jsx

@@ -41,7 +41,19 @@ class SearchForm extends React.Component {
   }
   }
 
 
   getHelpElement() {
   getHelpElement() {
-    const t = this.props.t;
+    const { t, appContainer } = this.props;
+
+    const config = appContainer.getConfig();
+    const isReachable = config.isSearchServiceReachable;
+
+    if (!isReachable) {
+      return (
+        <>
+          <h5 className="text-danger">Error occured on Search Service</h5>
+          Try to reconnect from management page.
+        </>
+      );
+    }
 
 
     return (
     return (
       <table className="table m-1 search-help">
       <table className="table m-1 search-help">
@@ -89,7 +101,14 @@ class SearchForm extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const t = this.props.t;
+    const { t, appContainer } = this.props;
+
+    const config = appContainer.getConfig();
+    const isReachable = config.isSearchServiceReachable;
+
+    const placeholder = isReachable
+      ? 'Search ...'
+      : 'Error on Search Service';
     const emptyLabel = (this.state.searchError !== null)
     const emptyLabel = (this.state.searchError !== null)
       ? 'Error on searching.'
       ? 'Error on searching.'
       : t('search.search page bodies');
       : t('search.search page bodies');
@@ -101,8 +120,8 @@ class SearchForm extends React.Component {
         onInputChange={this.props.onInputChange}
         onInputChange={this.props.onInputChange}
         onSearchError={this.onSearchError}
         onSearchError={this.onSearchError}
         emptyLabel={emptyLabel}
         emptyLabel={emptyLabel}
-        placeholder="Search ..."
-        promptText={this.getHelpElement()}
+        placeholder={placeholder}
+        helpElement={this.getHelpElement()}
         keywordOnInit={this.props.keyword}
         keywordOnInit={this.props.keyword}
       />
       />
     );
     );

+ 11 - 7
src/client/js/components/SearchTypeahead.jsx

@@ -123,8 +123,16 @@ class SearchTypeahead extends React.Component {
   }
   }
 
 
   getEmptyLabel() {
   getEmptyLabel() {
+    const { emptyLabel, helpElement } = this.props;
+    const { input } = this.state;
+
+    // show help element if empty
+    if (input.length === 0) {
+      return helpElement;
+    }
+
     // use props.emptyLabel as is if defined
     // use props.emptyLabel as is if defined
-    if (this.props.emptyLabel !== undefined) {
+    if (emptyLabel !== undefined) {
       return this.props.emptyLabel;
       return this.props.emptyLabel;
     }
     }
 
 
@@ -184,11 +192,8 @@ class SearchTypeahead extends React.Component {
           labelKey="path"
           labelKey="path"
           minLength={0}
           minLength={0}
           options={this.state.pages} // Search result (Some page names)
           options={this.state.pages} // Search result (Some page names)
+          promptText={this.props.helpElement}
           emptyLabel={this.getEmptyLabel()}
           emptyLabel={this.getEmptyLabel()}
-          searchText={(this.state.isLoading ? 'Searching...' : this.getEmptyLabel())}
-              // DIRTY HACK
-              //  note: The default searchText string has been shown wrongly even if isLoading is false
-              //        since upgrade react-bootstrap-typeahead to v3.3.2 -- 2019.02.05 Yuki Takei
           align="left"
           align="left"
           submitFormOnEnter
           submitFormOnEnter
           onSearch={this.search}
           onSearch={this.search}
@@ -197,7 +202,6 @@ class SearchTypeahead extends React.Component {
           renderMenuItemChildren={this.renderMenuItemChildren}
           renderMenuItemChildren={this.renderMenuItemChildren}
           caseSensitive={false}
           caseSensitive={false}
           defaultSelected={defaultSelected}
           defaultSelected={defaultSelected}
-          promptText={this.props.promptText}
         />
         />
         {restoreFormButton}
         {restoreFormButton}
       </div>
       </div>
@@ -229,7 +233,7 @@ SearchTypeahead.propTypes = {
   emptyLabelExceptError: PropTypes.string,
   emptyLabelExceptError: PropTypes.string,
   placeholder:     PropTypes.string,
   placeholder:     PropTypes.string,
   keywordOnInit:   PropTypes.string,
   keywordOnInit:   PropTypes.string,
-  promptText:      PropTypes.object,
+  helpElement:     PropTypes.object,
 };
 };
 
 
 /**
 /**

+ 15 - 13
src/client/styles/scss/_override-rbt.scss

@@ -1,13 +1,15 @@
-// override react-bootstrap-typeahead styles
-// see: https://github.com/ericgio/react-bootstrap-typeahead
-.rbt-input.form-control {
-  // focus
-  &.focus {
-    border-color: inherit;
-    box-shadow: none;
-  }
-}
-// hide loading icon
-.rbt-aux {
-  display: none;
-}
+// override react-bootstrap-typeahead styles
+// see: https://github.com/ericgio/react-bootstrap-typeahead
+.form-group:not(.has-error) {
+  .rbt-input.form-control {
+    // focus
+    &.focus {
+      border-color: inherit;
+      box-shadow: none;
+    }
+  }
+}
+// hide loading icon
+.rbt-aux {
+  display: none;
+}

+ 5 - 1
src/client/styles/scss/_search.scss

@@ -76,7 +76,6 @@
   // see: https://github.com/ericgio/react-bootstrap-typeahead
   // see: https://github.com/ericgio/react-bootstrap-typeahead
   .rbt-input.form-control {
   .rbt-input.form-control {
     height: 30px;
     height: 30px;
-    border: none;
     border-top-right-radius: 40px;
     border-top-right-radius: 40px;
     border-bottom-right-radius: 40px;
     border-bottom-right-radius: 40px;
 
 
@@ -84,6 +83,11 @@
       margin-left: 8px;
       margin-left: 8px;
     }
     }
   }
   }
+  .form-group:not(.has-error) {
+    .rbt-input.form-control {
+      border: none;
+    }
+  }
 
 
   .btn-group-submit-search {
   .btn-group-submit-search {
     position: absolute;
     position: absolute;

+ 3 - 2
src/server/crowi/index.js

@@ -333,9 +333,10 @@ Crowi.prototype.setupSearcher = async function() {
   const SearchService = require('@server/service/search');
   const SearchService = require('@server/service/search');
   const searchService = new SearchService(this);
   const searchService = new SearchService(this);
 
 
-  if (searchService.isAvailable) {
+  if (searchService.isConfigured) {
     try {
     try {
-      this.searcher = searchService;
+      this.searchService = searchService;
+      this.searcher = searchService; // TODO: use `searchService` instead of `searcher`
     }
     }
     catch (e) {
     catch (e) {
       logger.error('Error on setup searcher', e);
       logger.error('Error on setup searcher', e);

+ 1 - 0
src/server/models/config.js

@@ -210,6 +210,7 @@ module.exports = function(crowi) {
       recentCreatedLimit: crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
       recentCreatedLimit: crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
       isEnabledStaleNotification: crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
       isEnabledStaleNotification: crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
       isAclEnabled: crowi.aclService.isAclEnabled(),
       isAclEnabled: crowi.aclService.isAclEnabled(),
+      isSearchServiceReachable: crowi.searchService.isReachable,
       globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
       globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
     };
     };
 
 

+ 3 - 17
src/server/routes/admin.js

@@ -95,6 +95,9 @@ module.exports = function(crowi, app) {
   searchEvent.on('finishAddPage', (total, current, skip) => {
   searchEvent.on('finishAddPage', (total, current, skip) => {
     crowi.getIo().sockets.emit('admin:finishAddPage', { total, current, skip });
     crowi.getIo().sockets.emit('admin:finishAddPage', { total, current, skip });
   });
   });
+  searchEvent.on('rebuildingFailed', (error) => {
+    crowi.getIo().sockets.emit('admin:rebuildingFailed', { error: error.message });
+  });
 
 
   actions.index = function(req, res) {
   actions.index = function(req, res) {
     return res.render('admin/index');
     return res.render('admin/index');
@@ -994,23 +997,6 @@ module.exports = function(crowi, app) {
     }
     }
   };
   };
 
 
-
-  actions.api.searchBuildIndex = async function(req, res) {
-    const search = crowi.getSearcher();
-    if (!search) {
-      return res.json(ApiResponse.error('ElasticSearch Integration is not set up.'));
-    }
-
-    try {
-      search.buildIndex();
-    }
-    catch (err) {
-      return res.json(ApiResponse.error(err));
-    }
-
-    return res.json(ApiResponse.success());
-  };
-
   /**
   /**
    * validate setting form values for SAML
    * validate setting form values for SAML
    *
    *

+ 2 - 0
src/server/routes/apiv3/index.js

@@ -35,5 +35,7 @@ module.exports = (crowi) => {
 
 
   router.use('/statistics', require('./statistics')(crowi));
   router.use('/statistics', require('./statistics')(crowi));
 
 
+  router.use('/search', require('./search')(crowi));
+
   return router;
   return router;
 };
 };

+ 131 - 0
src/server/routes/apiv3/search.js

@@ -0,0 +1,131 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:search'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+const { body } = require('express-validator');
+
+const router = express.Router();
+
+const helmet = require('helmet');
+
+
+/**
+ * @swagger
+ *  tags:
+ *    name: Search
+ */
+module.exports = (crowi) => {
+  const accessTokenParser = require('../../middleware/access-token-parser')(crowi);
+  const loginRequired = require('../../middleware/login-required')(crowi);
+  const adminRequired = require('../../middleware/admin-required')(crowi);
+  const csrf = require('../../middleware/csrf')(crowi);
+
+  const { ApiV3FormValidator } = crowi.middlewares;
+
+  /**
+   * @swagger
+   *
+   *  /search/indices:
+   *    get:
+   *      tags: [Search]
+   *      summary: /search/indices
+   *      description: Get current status of indices
+   *      responses:
+   *        200:
+   *          description: Status of indices
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  info:
+   *                    type: object
+   */
+  router.get('/indices', helmet.noCache(), accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+    try {
+      const search = crowi.getSearcher();
+      const info = await search.getInfoForAdmin();
+      return res.status(200).send({ info });
+    }
+    catch (err) {
+      return res.apiv3Err(err, 503);
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *  /search/connection:
+   *    get:
+   *      tags: [Search]
+   *      summary: /search/connection
+   *      description: Reconnect to Elasticsearch
+   *      responses:
+   *        200:
+   *          description: Successfully connected
+   */
+  router.post('/connection', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+    try {
+      const search = crowi.getSearcher();
+      await search.initClient();
+      return res.status(200).send();
+    }
+    catch (err) {
+      return res.apiv3Err(err, 503);
+    }
+  });
+
+  const validatorForPutIndices = [
+    body('operation').isString().isIn(['rebuild', 'normalize']),
+  ];
+
+  /**
+   * @swagger
+   *
+   *  /search/indices:
+   *    put:
+   *      tags: [Search]
+   *      summary: /search/indices
+   *      description: Operate indices
+   *      requestBody:
+   *        required: true
+   *        content:
+   *          application/json:
+   *            schema:
+   *              properties:
+   *                operation:
+   *                  type: string
+   *                  description: Operation type against to indices >
+   *                    * `normalize` - Normalize indices
+   *                    * `rebuild` - Rebuild indices
+   *                  enum: [normalize, rebuild]
+   *      responses:
+   *        200:
+   *          description: Return 200
+   */
+  router.put('/indices', accessTokenParser, loginRequired, adminRequired, csrf, validatorForPutIndices, ApiV3FormValidator, async(req, res) => {
+    const operation = req.body.operation;
+
+    try {
+      const search = crowi.getSearcher();
+
+      switch (operation) {
+        case 'normalize':
+          // wait the processing is terminated
+          await search.normalizeIndices();
+          return res.status(200).send({ message: 'Operation is successfully processed.' });
+        case 'rebuild':
+          // NOT wait the processing is terminated
+          search.rebuildIndex();
+          return res.status(200).send({ message: 'Operation is successfully requested.' });
+        default:
+          throw new Error(`Unimplemented operation: ${operation}`);
+      }
+    }
+    catch (err) {
+      return res.apiv3Err(err);
+    }
+  });
+
+  return router;
+};

+ 0 - 1
src/server/routes/index.js

@@ -93,7 +93,6 @@ module.exports = function(crowi, app) {
 
 
   // search admin
   // search admin
   app.get('/admin/search'              , loginRequiredStrictly , adminRequired , admin.search.index);
   app.get('/admin/search'              , loginRequiredStrictly , adminRequired , admin.search.index);
-  app.post('/_api/admin/search/build'  , loginRequiredStrictly , adminRequired , csrf, admin.api.searchBuildIndex);
 
 
   // notification admin
   // notification admin
   app.get('/admin/notification'              , loginRequiredStrictly , adminRequired , admin.notification.index);
   app.get('/admin/notification'              , loginRequiredStrictly , adminRequired , admin.notification.index);

+ 1 - 1
src/server/routes/installer.js

@@ -17,7 +17,7 @@ module.exports = function(crowi, app) {
       return;
       return;
     }
     }
 
 
-    await search.buildIndex();
+    await search.rebuildIndex();
   }
   }
 
 
   async function createInitialPages(owner, lang) {
   async function createInitialPages(owner, lang) {

+ 85 - 62
src/server/service/search-delegator/elasticsearch.js

@@ -65,6 +65,38 @@ class ElasticsearchDelegator {
     this.indexName = indexName;
     this.indexName = indexName;
   }
   }
 
 
+  /**
+   * return information object to connect to ES
+   * @return {object} { host, httpAuth, indexName}
+   */
+  getConnectionInfo() {
+    let indexName = 'crowi';
+    let host = this.esUri;
+    let httpAuth = '';
+
+    const elasticsearchUri = this.configManager.getConfig('crowi', 'app:elasticsearchUri');
+
+    const url = new URL(elasticsearchUri);
+    if (url.pathname !== '/') {
+      host = `${url.protocol}//${url.host}`;
+      indexName = url.pathname.substring(1); // omit heading slash
+
+      if (url.username != null && url.password != null) {
+        httpAuth = `${url.username}:${url.password}`;
+      }
+    }
+
+    return {
+      host,
+      httpAuth,
+      indexName,
+    };
+  }
+
+  async init() {
+    return this.normalizeIndices();
+  }
+
   async getInfo() {
   async getInfo() {
     const info = await this.client.nodes.info();
     const info = await this.client.nodes.info();
     if (!info._nodes || !info.nodes) {
     if (!info._nodes || !info.nodes) {
@@ -94,88 +126,79 @@ class ElasticsearchDelegator {
     return { esVersion, esNodeInfos };
     return { esVersion, esNodeInfos };
   }
   }
 
 
-  /**
-   * return information object to connect to ES
-   * @return {object} { host, httpAuth, indexName}
-   */
-  getConnectionInfo() {
-    let indexName = 'crowi';
-    let host = this.esUri;
-    let httpAuth = '';
+  async getInfoForAdmin() {
+    const { client, indexName, aliasName } = this;
 
 
-    const elasticsearchUri = this.configManager.getConfig('crowi', 'app:elasticsearchUri');
+    const tmpIndexName = `${indexName}-tmp`;
 
 
-    const url = new URL(elasticsearchUri);
-    if (url.pathname !== '/') {
-      host = `${url.protocol}//${url.host}`;
-      indexName = url.pathname.substring(1); // omit heading slash
+    const { indices } = await client.indices.stats({ index: `${indexName}*`, ignore_unavailable: true, metric: ['docs', 'store', 'indexing'] });
 
 
-      if (url.username != null && url.password != null) {
-        httpAuth = `${url.username}:${url.password}`;
-      }
-    }
+    const aliases = await client.indices.getAlias({ index: `${indexName}*` });
+    const isExistsMainIndex = aliases[indexName] != null;
+    const isMainIndexHasAlias = isExistsMainIndex && aliases[indexName].aliases != null && aliases[indexName].aliases[aliasName] != null;
+    const isExistsTmpIndex = aliases[tmpIndexName] != null;
+    const isTmpIndexHasAlias = isExistsTmpIndex && aliases[tmpIndexName].aliases != null && aliases[tmpIndexName].aliases[aliasName] != null;
+
+    const isNormalized = isExistsMainIndex && isMainIndexHasAlias && !isExistsTmpIndex && !isTmpIndexHasAlias;
 
 
     return {
     return {
-      host,
-      httpAuth,
-      indexName,
+      indices,
+      aliases,
+      isNormalized,
     };
     };
   }
   }
 
 
-  async init() {
-    return this.initIndices();
-  }
-
   /**
   /**
-   * build index
+   * rebuild index
    */
    */
-  async buildIndex() {
+  async rebuildIndex() {
     const { client, indexName, aliasName } = this;
     const { client, indexName, aliasName } = this;
 
 
     const tmpIndexName = `${indexName}-tmp`;
     const tmpIndexName = `${indexName}-tmp`;
 
 
-    // reindex to tmp index
-    await this.createIndex(tmpIndexName);
-    await client.reindex({
-      waitForCompletion: false,
-      body: {
-        source: { index: indexName },
-        dest: { index: tmpIndexName },
-      },
-    });
+    try {
+      // reindex to tmp index
+      await this.createIndex(tmpIndexName);
+      await client.reindex({
+        waitForCompletion: false,
+        body: {
+          source: { index: indexName },
+          dest: { index: tmpIndexName },
+        },
+      });
 
 
-    // update alias
-    await client.indices.updateAliases({
-      body: {
-        actions: [
-          { add: { alias: aliasName, index: tmpIndexName } },
-          { remove: { alias: aliasName, index: indexName } },
-        ],
-      },
-    });
+      // update alias
+      await client.indices.updateAliases({
+        body: {
+          actions: [
+            { add: { alias: aliasName, index: tmpIndexName } },
+            { remove: { alias: aliasName, index: indexName } },
+          ],
+        },
+      });
 
 
-    // flush index
-    await client.indices.delete({
-      index: indexName,
-    });
-    await this.createIndex(indexName);
-    await this.addAllPages();
+      // flush index
+      await client.indices.delete({
+        index: indexName,
+      });
+      await this.createIndex(indexName);
+      await this.addAllPages();
+    }
+    catch (error) {
+      logger.warn('An error occured while \'rebuildIndex\', normalize indices anyway.');
 
 
-    // update alias
-    await client.indices.updateAliases({
-      body: {
-        actions: [
-          { add: { alias: aliasName, index: indexName } },
-          { remove: { alias: aliasName, index: tmpIndexName } },
-        ],
-      },
-    });
+      const { searchEvent } = this;
+      searchEvent.emit('rebuildingFailed', error);
+
+      throw error;
+    }
+    finally {
+      await this.normalizeIndices();
+    }
 
 
-    // remove tmp index
-    await client.indices.delete({ index: tmpIndexName });
   }
   }
 
 
-  async initIndices() {
+  async normalizeIndices() {
     const { client, indexName, aliasName } = this;
     const { client, indexName, aliasName } = this;
 
 
     const tmpIndexName = `${indexName}-tmp`;
     const tmpIndexName = `${indexName}-tmp`;

+ 1 - 1
src/server/service/search-delegator/searchbox.js

@@ -25,7 +25,7 @@ class SearchboxDelegator extends ElasticsearchDelegator {
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
-  async buildIndex() {
+  async rebuildIndex() {
     const { client, indexName, aliasName } = this;
     const { client, indexName, aliasName } = this;
 
 
     // flush index
     // flush index

+ 41 - 6
src/server/service/search.js

@@ -7,6 +7,8 @@ class SearchService {
     this.crowi = crowi;
     this.crowi = crowi;
     this.configManager = crowi.configManager;
     this.configManager = crowi.configManager;
 
 
+    this.isErrorOccured = null;
+
     try {
     try {
       this.delegator = this.initDelegator();
       this.delegator = this.initDelegator();
     }
     }
@@ -14,16 +16,20 @@ class SearchService {
       logger.error(err);
       logger.error(err);
     }
     }
 
 
-    if (this.isAvailable) {
+    if (this.isConfigured) {
       this.delegator.init();
       this.delegator.init();
       this.registerUpdateEvent();
       this.registerUpdateEvent();
     }
     }
   }
   }
 
 
-  get isAvailable() {
+  get isConfigured() {
     return this.delegator != null;
     return this.delegator != null;
   }
   }
 
 
+  get isReachable() {
+    return this.isConfigured && !this.isErrorOccured;
+  }
+
   get isSearchboxEnabled() {
   get isSearchboxEnabled() {
     return this.configManager.getConfig('crowi', 'app:searchboxSslUrl') != null;
     return this.configManager.getConfig('crowi', 'app:searchboxSslUrl') != null;
   }
   }
@@ -64,16 +70,45 @@ class SearchService {
     tagEvent.on('update', this.delegator.syncTagChanged.bind(this.delegator));
     tagEvent.on('update', this.delegator.syncTagChanged.bind(this.delegator));
   }
   }
 
 
+  async initClient() {
+    // reset error flag
+    this.isErrorOccured = false;
+
+    return this.delegator.initClient();
+  }
+
   async getInfo() {
   async getInfo() {
-    return this.delegator.getInfo();
+    try {
+      return await this.delegator.getInfo();
+    }
+    catch (err) {
+      // switch error flag, `isReachable` to be `false`
+      this.isErrorOccured = true;
+      throw err;
+    }
+  }
+
+  async getInfoForAdmin() {
+    return this.delegator.getInfoForAdmin();
+  }
+
+  async normalizeIndices() {
+    return this.delegator.normalizeIndices();
   }
   }
 
 
-  async buildIndex() {
-    return this.delegator.buildIndex();
+  async rebuildIndex() {
+    return this.delegator.rebuildIndex();
   }
   }
 
 
   async searchKeyword(keyword, user, userGroups, searchOpts) {
   async searchKeyword(keyword, user, userGroups, searchOpts) {
-    return this.delegator.searchKeyword(keyword, user, userGroups, searchOpts);
+    try {
+      return await this.delegator.searchKeyword(keyword, user, userGroups, searchOpts);
+    }
+    catch (err) {
+      // switch error flag, `isReachable` to be `false`
+      this.isErrorOccured = true;
+      throw err;
+    }
   }
   }
 
 
 }
 }

+ 3 - 5
src/server/util/swigFunctions.js

@@ -114,11 +114,9 @@ module.exports = function(crowi, req, locals) {
     return crowi.passportService.getSamlMissingMandatoryConfigKeys();
     return crowi.passportService.getSamlMissingMandatoryConfigKeys();
   };
   };
 
 
-  locals.searchConfigured = function() {
-    if (crowi.getSearcher()) {
-      return true;
-    }
-    return false;
+  locals.isSearchServiceConfigured = function() {
+    const searchService = crowi.getSearcher();
+    return searchService != null && searchService.isConfigured;
   };
   };
 
 
   locals.isHackmdSetup = function() {
   locals.isHackmdSetup = function() {

+ 1 - 1
src/server/views/layout-crowi/page_list.html

@@ -39,7 +39,7 @@
   # so now bind the values through the hidden fields.
   # so now bind the values through the hidden fields.
   #}
   #}
   {% if false %} {# Disable temporaly -- 2018.03.08 Yuki Takei #}
   {% if false %} {# Disable temporaly -- 2018.03.08 Yuki Takei #}
-  {% if searchConfigured() && !isTopPage() && !isTrashPage() %}
+  {% if isSearchServiceConfigured() && !isTopPage() && !isTrashPage() %}
   <div id="page-list-search">
   <div id="page-list-search">
   </div>
   </div>
   {% endif %}
   {% endif %}

+ 2 - 2
src/server/views/layout/layout.html

@@ -115,7 +115,7 @@
         </li>
         </li>
         #}
         #}
         <li>
         <li>
-          {% if searchConfigured() %}
+          {% if isSearchServiceConfigured() %}
           <div class="navbar-form navbar-left search-top" role="search" id="search-top"></div>
           <div class="navbar-form navbar-left search-top" role="search" id="search-top"></div>
           {% endif %}
           {% endif %}
         </li>
         </li>
@@ -177,7 +177,7 @@
     <div class="sidebar-nav navbar-collapse slimscrollsidebar">
     <div class="sidebar-nav navbar-collapse slimscrollsidebar">
       <ul class="nav" id="side-menu">
       <ul class="nav" id="side-menu">
         <li class="sidebar-search hidden-sm hidden-md hidden-lg">
         <li class="sidebar-search hidden-sm hidden-md hidden-lg">
-          {% if searchConfigured() %}
+          {% if isSearchServiceConfigured() %}
           <div class="search-sidebar" role="search" id="search-sidebar"></div>
           <div class="search-sidebar" role="search" id="search-sidebar"></div>
           {% endif %}
           {% endif %}
         </li>
         </li>

+ 1 - 1
src/server/views/modal/create_page.html

@@ -31,7 +31,7 @@
             <legend>{{ t('Create under') }}</legend>
             <legend>{{ t('Create under') }}</legend>
             <div class="d-flex create-page-input-container">
             <div class="d-flex create-page-input-container">
               <div class="create-page-input-row d-flex align-items-center">
               <div class="create-page-input-row d-flex align-items-center">
-                {% if searchConfigured() %}
+                {% if isSearchServiceConfigured() %}
                 <div id="create-page-name-input" class="page-name-input"></div>
                 <div id="create-page-name-input" class="page-name-input"></div>
                 {% else %}
                 {% else %}
                 <input type="text" value="{{ parentPath(path) }}" class="page-name-input form-control " placeholder="{{ t('Input page name') }}" required />
                 <input type="text" value="{{ parentPath(path) }}" class="page-name-input form-control " placeholder="{{ t('Input page name') }}" required />

+ 1 - 1
src/server/views/modal/duplicate.html

@@ -17,7 +17,7 @@
               <label for="duplicatePageName">{{ t('modal_duplicate.label.New page name') }}</label><br>
               <label for="duplicatePageName">{{ t('modal_duplicate.label.New page name') }}</label><br>
               <div class="input-group">
               <div class="input-group">
                 <span class="input-group-addon">{{ baseUrl }}</span>
                 <span class="input-group-addon">{{ baseUrl }}</span>
-                {% if searchConfigured() %}
+                {% if isSearchServiceConfigured() %}
                 <div id="duplicate-page-name-input" class="page-name-input"></div>
                 <div id="duplicate-page-name-input" class="page-name-input"></div>
                 {% else %}
                 {% else %}
                 <input type="text" class="form-control" name="new_path" id="duplicatePageName" value="{{ page.path }}">
                 <input type="text" class="form-control" name="new_path" id="duplicatePageName" value="{{ page.path }}">

+ 1 - 1
src/server/views/modal/rename.html

@@ -17,7 +17,7 @@
             <label for="newPageName">{{ t('modal_rename.label.New page name') }}</label><br>
             <label for="newPageName">{{ t('modal_rename.label.New page name') }}</label><br>
             <div class="input-group">
             <div class="input-group">
               <span class="input-group-addon">{{ baseUrl }}</span>
               <span class="input-group-addon">{{ baseUrl }}</span>
-              {% if searchConfigured() %}
+              {% if isSearchServiceConfigured() %}
               <div id="rename-page-name-input" class="page-name-input"></div>
               <div id="rename-page-name-input" class="page-name-input"></div>
               {% else %}
               {% else %}
               <input type="text" class="form-control" name="new_path" id="newPageName" value="{{ page.path }}">
               <input type="text" class="form-control" name="new_path" id="newPageName" value="{{ page.path }}">

+ 17 - 8
yarn.lock

@@ -3571,12 +3571,13 @@ create-react-context@^0.1.5:
   resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.1.6.tgz#0f425931d907741127acc6e31acb4f9015dd9fdc"
   resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.1.6.tgz#0f425931d907741127acc6e31acb4f9015dd9fdc"
   integrity sha512-eCnYYEUEc5i32LHwpE/W7NlddOB9oHwsPaWtWzYtflNkkwa3IfindIcoXdVWs12zCbwaMCavKNu84EXogVIWHw==
   integrity sha512-eCnYYEUEc5i32LHwpE/W7NlddOB9oHwsPaWtWzYtflNkkwa3IfindIcoXdVWs12zCbwaMCavKNu84EXogVIWHw==
 
 
-create-react-context@^0.2.3:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.2.3.tgz#9ec140a6914a22ef04b8b09b7771de89567cb6f3"
+create-react-context@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.3.0.tgz#546dede9dc422def0d3fc2fe03afe0bc0f4f7d8c"
+  integrity sha512-dNldIoSuNSvlTJ7slIKC/ZFGKexBMBrrcc+TTe1NdmROnaASuLPvqpwj9v4XS4uXZ8+YPu0sNmShX2rXI5LNsw==
   dependencies:
   dependencies:
-    fbjs "^0.8.0"
     gud "^1.0.0"
     gud "^1.0.0"
+    warning "^4.0.3"
 
 
 cross-env@^6.0.3:
 cross-env@^6.0.3:
   version "6.0.3"
   version "6.0.3"
@@ -10700,12 +10701,13 @@ rc@^1.1.7:
     minimist "^1.2.0"
     minimist "^1.2.0"
     strip-json-comments "~2.0.1"
     strip-json-comments "~2.0.1"
 
 
-react-bootstrap-typeahead@^3.4.2:
-  version "3.4.2"
-  resolved "https://registry.yarnpkg.com/react-bootstrap-typeahead/-/react-bootstrap-typeahead-3.4.2.tgz#d0090488854a8597387007ced641dd5fbfc901f7"
+react-bootstrap-typeahead@^3.4.7:
+  version "3.4.7"
+  resolved "https://registry.yarnpkg.com/react-bootstrap-typeahead/-/react-bootstrap-typeahead-3.4.7.tgz#27a3f17c6b1351a0c1b321ac133d5e762cf4dc2a"
+  integrity sha512-eUm3hqX12p+iM+1Y0HKF891/ACbKyGep7PsC2pjFGZL48r25Jlv3X2xmV5D8N0wE/YPFZF7iW913tyAlwqjw1Q==
   dependencies:
   dependencies:
     classnames "^2.2.0"
     classnames "^2.2.0"
-    create-react-context "^0.2.3"
+    create-react-context "^0.3.0"
     escape-string-regexp "^1.0.5"
     escape-string-regexp "^1.0.5"
     invariant "^2.2.1"
     invariant "^2.2.1"
     lodash "^4.17.2"
     lodash "^4.17.2"
@@ -13605,6 +13607,13 @@ warning@^4.0.1, warning@^4.0.2:
   dependencies:
   dependencies:
     loose-envify "^1.0.0"
     loose-envify "^1.0.0"
 
 
+warning@^4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
+  integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
+  dependencies:
+    loose-envify "^1.0.0"
+
 watchpack@^1.6.0:
 watchpack@^1.6.0:
   version "1.6.0"
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00"
   resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00"