Browse Source

[ui-configuration] Convert the Configuration page within Administrator Server in ReactJS (#3848)

* Configuration page in ReactJS

Done till before bringing config in alignment

styling changes

fixing checks

newly added

WIP

WIP

WIP

Renaming

Linting fixed

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

hey

WIP

WIP

WIP

WIP

wIP

WIP

WIP

* Changed the metrics Filter to show only the desired row after filtering

Linting fixed

* Changes based on review comments
Ananya_Agarwal 1 year ago
parent
commit
3d0bf36db2

+ 0 - 99
apps/beeswax/src/beeswax/templates/configuration.mako

@@ -1,99 +0,0 @@
-## Licensed to Cloudera, Inc. under one
-## or more contributor license agreements.  See the NOTICE file
-## distributed with this work for additional information
-## regarding copyright ownership.  Cloudera, Inc. licenses this file
-## to you under the Apache License, Version 2.0 (the
-## "License"); you may not use this file except in compliance
-## with the License.  You may obtain a copy of the License at
-##
-##     http://www.apache.org/licenses/LICENSE-2.0
-##
-## Unless required by applicable law or agreed to in writing, software
-## distributed under the License is distributed on an "AS IS" BASIS,
-## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-## See the License for the specific language governing permissions and
-## limitations under the License.
-<%!
-import sys
-
-from desktop.views import commonheader, commonfooter
-
-if sys.version_info[0] > 2:
-  from django.utils.translation import gettext as _
-else:
-  from django.utils.translation import ugettext as _
-%>
-
-<%namespace name="layout" file="layout.mako" />
-<%namespace name="util" file="util.mako" />
-
-${ commonheader(_('Settings'), app_name, user, request) | n,unicode }
-${layout.menubar(section='configuration')}
-
-<div class="container-fluid">
-  <div class="card card-small">
-	  <h1 class="card-heading simple">${_('Settings')}</h1>
-    <div class="card-body">
-      <p>
-        <form class="form-search" method="POST">
-          ${ csrf_token(request) | n,unicode }
-          <input type="text" id="filterInput" class="input-xlarge search-query" placeholder="${_('Search for key or value.')}">
-          <a href="#" id="clearFilterBtn" class="btn">${_('Clear')}</a>
-        </form>
-        <table class="table table-condensed datatables">
-          <thead>
-            <tr>
-              <th>${_('Key')}</th>
-              <th>${_('Value')}</th>
-            </tr>
-          </thead>
-          <tbody>
-            % for key, value in configuration.items():
-              <tr class="confRow" data-search="${key or ""}${value or ""}">
-                  <td>${key or ""}</td><td>${value or ""}</td>
-              </tr>
-            % endfor
-          </tbody>
-        </table>
-      </p>
-    </div>
-  </div>
-</div>
-
-<script type="text/javascript">
-	$(document).ready(function(){
-		$(".datatables").dataTable({
-			"bPaginate": false,
-		    "bLengthChange": false,
-		    "bFilter": false,
-			"bInfo": false,
-            "oLanguage": {
-                "sEmptyTable": "${_('No data available')}",
-                "sZeroRecords": "${_('No matching records')}",
-            }
-		});
-
-		var searchTimeoutId = 0;
-		$("#filterInput").keyup(function(){
-			window.clearTimeout(searchTimeoutId);
-			searchTimeoutId = window.setTimeout(function(){
-				$.each($(".confRow"), function(index, value) {
-		          if($(value).data("search").toLowerCase().indexOf($("#filterInput").val().toLowerCase()) == -1 && $("#filterInput").val() != ""){
-		            $(value).hide();
-		          }else{
-		            $(value).show();
-		          }
-		        });
-			}, 500);
-	    });
-
-		$("#clearFilterBtn").click(function(){
-	        $("#filterInput").val("");
-	        $.each($(".confRow"), function(index, value) {
-	            $(value).show();
-	        });
-	    });
-	});
-</script>
-
-${ commonfooter(request, messages) | n,unicode }

+ 0 - 217
desktop/core/src/desktop/js/apps/about/components/ko.hueConfigTree.js

@@ -1,217 +0,0 @@
-// Licensed to Cloudera, Inc. under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  Cloudera, Inc. licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import * as ko from 'knockout';
-
-import apiHelper from 'api/apiHelper';
-import componentUtils from 'ko/components/componentUtils';
-import I18n from 'utils/i18n';
-import DisposableComponent from 'ko/components/DisposableComponent';
-
-export const NAME = 'hue-config-tree';
-
-// prettier-ignore
-const TEMPLATE = `
-<script id="config-values-template" type="text/html">
-  <tr>
-    <th>
-    <!-- ko if: config.is_anonymous -->
-      <i>(${ I18n('default section') })</i>
-    <!-- /ko -->
-    <!-- ko ifnot: config.is_anonymous -->
-      <span data-bind="text: config.key"></span>
-    <!-- /ko -->
-    </th>
-    <td data-bind="css: { 'border-top': depth === 1 ? '0' : null }">
-      <!-- ko if: typeof config.values !== 'undefined' -->
-        <!-- ko if: config.help || !config.values.length -->
-        <i data-bind="text: config.help || '${ I18n('No help available.') }'"></i>
-        <!-- /ko -->
-        <table class="table table-condensed recurse">
-          <tbody>
-            <!-- ko foreach: config.values -->
-              <!-- ko template: { name: 'config-values-template', data: { config: $data, depth: $parent.depth + 1 } } --><!-- /ko -->
-            <!-- /ko -->
-          </tbody>
-        </table>
-        <!-- /ko -->
-      <!-- ko if: typeof config.value !== 'undefined' -->
-        <code data-bind="text: config.value"></code><br/>
-        <i data-bind="text: config.help || '${ I18n('No help available.') }'"></i>
-        <span class="muted">${ I18n('Default:') } <i data-bind="text: config.default"></i></span>
-      <!-- /ko -->
-    </td>
-  </tr>
-</script>
-
-<div class="container-fluid">
-  <!-- ko hueSpinner: { spin:  loading, center: true, size: 'large' } --><!-- /ko -->
-  <!-- ko ifnot: loading -->
-    <!-- ko if: errorMessage -->
-      <div class="alert alert-error" style="margin: 20px" data-bind="text: errorMessage"></div>
-    <!-- /ko -->
-    <!-- ko ifnot: errorMessage -->
-      <div class="row-fluid">
-        <div class="card card-home">
-          <div class="pull-right muted margin-top-30">
-            ${ I18n('Configuration files located in') } <code style="color: #0B7FAD" data-bind="text: configDir"></code>
-          </div>
-          <h1 class="inline-block margin-top-20">
-            ${ I18n('Sections') }
-          </h1>
-          <form class="form-inline" autocomplete="off">
-            <input class="inline-autocomp-input" type="text" ${ window.PREVENT_AUTOFILL_INPUT_ATTRS } placeholder="${ I18n('Filter...') }" data-bind="textInput: filter, clearable: { value: filter }">
-          </form>
-          <div class="card-body clearfix">
-            <div class="span2">
-              <ul class="nav nav-pills nav-stacked" data-bind="foreach: filteredSections">
-                <li data-bind="
-                    css: { 'active': $data.key === $parent.selectedKey() },
-                    click: function () { $parent.selectedKey($data.key) }
-                  ">
-                  <a href="javascript: void(0);" data-bind="text: key"></a>
-                </li>
-              </ul>
-            </div>
-            <div class="span10" data-bind="with: selectedConfig">
-              <div class="tab-content">
-                <div class="tab-pane active">
-                  <!-- ko if: values.length -->
-                  <table class="table table-condensed recurse">
-                    <tbody data-bind="foreach: values">
-                      <!-- ko template: { name: 'config-values-template', data: { config: $data, depth: 1 } } --><!-- /ko -->
-                    </tbody>
-                  </table>
-                  <!-- /ko -->
-                  <!-- ko ifnot: values.length -->
-                    <i>${ I18n('Empty configuration section') }</i>
-                  <!-- /ko -->
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <div class="card card-home" style="margin-top: 50px">
-          <h2 class="card-heading simple">${ I18n('Installed Applications') }</h2>
-          <div class="card-body" data-bind="foreach: apps">
-            <!-- ko if: has_ui -->
-              <a href="javascript: void(0);" data-bind="hueLink: display_name"><span class="badge badge-info" data-bind="text: name"></span></a>
-            <!-- /ko -->
-            <!-- ko ifnot: has_ui -->
-              <span class="badge" title="${ I18n('This app does not have a UI') }" data-bind="text: name"></span>
-            <!-- /ko -->
-          </div>
-        </div>
-      </div>
-    <!-- /ko -->
-  <!-- /ko -->
-</div>
-`;
-
-const filterConfig = (config, lowerCaseFilter) => {
-  if (
-    (config.value && config.value.indexOf(lowerCaseFilter) !== -1) ||
-    (!config.is_anonymous && config.key.indexOf(lowerCaseFilter) !== -1)
-  ) {
-    return config;
-  }
-
-  if (config.values) {
-    const values = [];
-    config.values.forEach(val => {
-      const filtered = filterConfig(val, lowerCaseFilter);
-      if (filtered) {
-        values.push(filtered);
-      }
-    });
-    if (values.length) {
-      return {
-        key: config.key,
-        is_anonymous: config.is_anonymous,
-        help: config.help,
-        values: values
-      };
-    }
-  }
-};
-
-class HueConfigTree extends DisposableComponent {
-  constructor(params) {
-    super();
-    this.loading = ko.observable(true);
-
-    this.filter = ko.observable().extend({ throttle: 500 });
-
-    this.errorMessage = ko.observable();
-    this.apps = ko.observableArray();
-    this.config = ko.observableArray();
-    this.configDir = ko.observable();
-    this.selectedKey = ko.observable();
-
-    this.filteredSections = ko.pureComputed(() => {
-      if (!this.filter()) {
-        return this.config();
-      }
-      const lowerCaseFilter = this.filter().toLowerCase();
-
-      const foundConfigs = [];
-      let selectedFound = false;
-      this.config().forEach(config => {
-        const filtered = filterConfig(config, lowerCaseFilter);
-        if (filtered) {
-          foundConfigs.push(filtered);
-          if (this.selectedKey() && this.selectedKey() === filtered.key) {
-            selectedFound = true;
-          }
-        }
-      });
-
-      if (!selectedFound) {
-        if (foundConfigs.length) {
-          this.selectedKey(foundConfigs[0].key);
-        } else {
-          this.selectedKey(undefined);
-        }
-      }
-
-      return foundConfigs;
-    });
-
-    this.selectedConfig = ko.pureComputed(() =>
-      this.filteredSections().find(section => section.key === this.selectedKey())
-    );
-
-    this.load();
-  }
-
-  async load() {
-    this.loading(true);
-    this.errorMessage(undefined);
-    try {
-      const response = await apiHelper.fetchHueConfigAsync();
-      this.config(response.config);
-      this.configDir(response.conf_dir);
-      this.apps(response.apps);
-      this.selectedKey(this.config().length ? this.config()[0].key : undefined);
-    } catch (err) {
-      this.errorMessage(err);
-    }
-    this.loading(false);
-  }
-}
-
-componentUtils.registerComponent(NAME, HueConfigTree, TEMPLATE);

+ 47 - 0
desktop/core/src/desktop/js/apps/admin/AdminHeader.scss

@@ -0,0 +1,47 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// 'License'); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+@import '../../components/styles/variables';
+
+.antd.cuix {
+  .admin-header {
+    display: flex;
+    align-items: center;
+
+    .admin-header__select-dropdown{
+      border: 1px solid $fluidx-gray-600;
+      border-radius: $border-radius-base;
+      background-color: $fluidx-white;
+      width: 25%;
+      height: 32px;
+    }
+
+    .admin-header__input-filter{
+      margin: $font-size-sm;
+      width: 25%;
+
+      input {
+        box-shadow: none;
+        -webkit-box-shadow: none;
+      }
+    }
+
+    .config__file-location-value {
+      color: $fluidx-blue-600;
+      display: block;
+    }
+  }
+}

+ 125 - 0
desktop/core/src/desktop/js/apps/admin/AdminHeader.test.tsx

@@ -0,0 +1,125 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// 'License'); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react';
+import { render, fireEvent, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import AdminHeader from './AdminHeader';
+
+// Mock data for the component
+const options = ['Option 1', 'Option 2', 'Option 3'];
+const mockOnSelectChange = jest.fn();
+const mockOnFilterChange = jest.fn();
+
+test('renders AdminHeader with correct dropdown and input filter', () => {
+  render(
+    <AdminHeader
+      options={options}
+      selectedValue="Option 1"
+      onSelectChange={mockOnSelectChange}
+      filterValue=""
+      onFilterChange={mockOnFilterChange}
+      placeholder="Filter data..."
+    />
+  );
+
+  expect(screen.getByText('Option 1')).toBeInTheDocument();
+  expect(screen.queryByText('Option 2')).not.toBeInTheDocument();
+  expect(screen.queryByText('Option 3')).not.toBeInTheDocument();
+
+  expect(screen.getByPlaceholderText('Filter data...')).toBeInTheDocument();
+});
+
+test('Calls onSelectChange with correct value when a different option is selected from the dropdown', async () => {
+  render(
+    <AdminHeader
+      options={options}
+      selectedValue="Option 1"
+      onSelectChange={mockOnSelectChange}
+      filterValue=""
+      onFilterChange={mockOnFilterChange}
+      placeholder="Filter data..."
+    />
+  );
+
+  const selectDropdown = screen.getByTestId('admin-header--select').firstElementChild;
+
+  if (selectDropdown) {
+    fireEvent.mouseDown(selectDropdown);
+  }
+
+  const dropdown = document.querySelector('.ant-select');
+
+  const secondOption = dropdown?.querySelectorAll('.ant-select-item')[1];
+  if (secondOption) {
+    fireEvent.click(secondOption);
+  }
+
+  expect(mockOnSelectChange).toHaveBeenCalledWith('Option 2');
+});
+
+test('Typing in the input filter should trigger a callback', async () => {
+  render(
+    <AdminHeader
+      options={options}
+      selectedValue="Option 1"
+      onSelectChange={mockOnSelectChange}
+      filterValue=""
+      onFilterChange={mockOnFilterChange}
+      placeholder="Filter data..."
+    />
+  );
+
+  const inputFilter = screen.getByPlaceholderText('Filter data...');
+
+  fireEvent.change(inputFilter, { target: { value: 'new filter' } });
+
+  expect(mockOnFilterChange).toHaveBeenCalledWith('new filter');
+});
+
+test('renders configAddress correctly when passed as prop', () => {
+  const configAddressValue = 'path/to/config';
+
+  render(
+    <AdminHeader
+      options={options}
+      selectedValue="Option 1"
+      onSelectChange={mockOnSelectChange}
+      filterValue=""
+      onFilterChange={mockOnFilterChange}
+      placeholder="Filter data..."
+      configAddress={configAddressValue}
+    />
+  );
+
+  expect(screen.getByText('Configuration files location:')).toBeInTheDocument();
+  expect(screen.getByText(configAddressValue)).toBeInTheDocument();
+});
+
+test('does not render configAddress when not passed as prop', () => {
+  render(
+    <AdminHeader
+      options={options}
+      selectedValue="Option 1"
+      onSelectChange={mockOnSelectChange}
+      filterValue=""
+      onFilterChange={mockOnFilterChange}
+      placeholder="Filter data..."
+    />
+  );
+
+  expect(screen.queryByText('Configuration files location:')).not.toBeInTheDocument();
+});

+ 80 - 0
desktop/core/src/desktop/js/apps/admin/AdminHeader.tsx

@@ -0,0 +1,80 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// 'License'); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react';
+import { Input } from 'antd';
+import Select from 'cuix/dist/components/Select/Select';
+import { SearchOutlined } from '@ant-design/icons';
+import { i18nReact } from '../../utils/i18nReact';
+import './AdminHeader.scss';
+
+const { Option } = Select;
+
+interface AdminHeaderProps {
+  options: string[];
+  selectedValue: string;
+  onSelectChange: (value: string) => void;
+  filterValue: string;
+  onFilterChange: (value: string) => void;
+  placeholder: string;
+  configAddress?: string;
+}
+
+const AdminHeader: React.FC<AdminHeaderProps> = ({
+  options,
+  selectedValue,
+  onSelectChange,
+  filterValue,
+  onFilterChange,
+  placeholder,
+  configAddress
+}) => {
+  const { t } = i18nReact.useTranslation();
+  return (
+    <div className="admin-header">
+      <Select
+        value={selectedValue}
+        onChange={value => onSelectChange(value)}
+        className="admin-header__select-dropdown"
+        getPopupContainer={triggerNode => triggerNode.parentElement}
+        data-testid="admin-header--select"
+      >
+        {options.map(option => (
+          <Option key={option} value={option}>
+            {option}
+          </Option>
+        ))}
+      </Select>
+
+      <Input
+        className="admin-header__input-filter"
+        placeholder={placeholder}
+        prefix={<SearchOutlined />}
+        value={filterValue}
+        onChange={e => onFilterChange(e.target.value)}
+      />
+
+      {configAddress && (
+        <span>
+          {t('Configuration files location:')}
+          <span className="config__file-location-value">{configAddress}</span>
+        </span>
+      )}
+    </div>
+  );
+};
+
+export default AdminHeader;

+ 90 - 0
desktop/core/src/desktop/js/apps/admin/Configuration/Configuration.scss

@@ -0,0 +1,90 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// 'License'); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+@import '../../../components/styles/variables';
+
+.antd.cuix {
+  .config-component {
+    background-color: $fluidx-gray-100;
+    padding: 24px;
+
+    .config__section-header {
+      color: $fluidx-gray-700;
+    }
+
+    .config__main-item {
+      padding: 16px 0 8px 16px;
+      border-bottom: solid 1px $fluidx-gray-300;
+      background-color: $fluidx-white;
+
+      .config--last-heading,
+      .config__main-item--heading {
+        font-size: $font-size-xl;
+        font-weight: 300;
+      }
+
+      .config--last-heading {
+        padding: 4px 0 8px 0;
+      }
+    }
+
+    .config__main-item .config__child-item .config--last-heading {
+      font-size: $font-size-base;
+    }
+
+    .config__child-item {
+      font-size: $font-size-base;
+      color: $fluidx-black;
+      font-weight: 400;
+      margin-left: 40px;
+    }
+
+    .config__last-item--help-text {
+      color: $fluidx-gray-700;
+      padding: 4px 8px 12px 21px;
+    }
+
+    .config__default-section--help-text {
+      padding: 8px 0 16px 0;
+      display: block;
+      font-size: $font-size-base;
+      color: $fluidx-black;
+      font-weight: 400;
+    }
+
+    .config__default-value {
+      color: $fluidx-gray-500;
+      font-style: italic;
+    }
+
+    .config__set-value {
+      color: $fluidx-gray-900;
+      padding: 0 4px;
+      font-size: $font-size-sm;
+      border-radius: 20px;
+      background-color: $fluidx-gray-200;
+      border: 1px solid $hue-border-color;
+      margin-left: 8px;
+    }
+
+    .config__help-tooltip {
+      margin-left: 8px;
+      cursor: pointer;
+      color: $fluidx-blue-600;
+      font-size: $font-size-base;
+    }
+  }
+}

+ 63 - 0
desktop/core/src/desktop/js/apps/admin/Configuration/ConfigurationKey.tsx

@@ -0,0 +1,63 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// 'License'); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react';
+import { Tooltip } from 'antd';
+import { InfoCircleOutlined } from '@ant-design/icons';
+import { AdminConfigValue } from './ConfigurationTab';
+import './Configuration.scss';
+
+export const ConfigurationKey: React.FC<{ record: AdminConfigValue }> = ({ record }) => {
+  if (record.is_anonymous) {
+    return (
+      <div>
+        Default section
+        {record.help && (
+          <Tooltip title={record.help}>
+            <InfoCircleOutlined className="config__help-tooltip" />
+          </Tooltip>
+        )}
+      </div>
+    );
+  }
+  if (record?.values) {
+    return (
+      <span>
+        <span className="config__main-item--heading">{record.key}</span>
+        {record.help && (
+          <Tooltip title={record.help}>
+            <InfoCircleOutlined className="config__help-tooltip" />
+          </Tooltip>
+        )}
+      </span>
+    );
+  } else {
+    return (
+      <span>
+        <span className="config--last-heading">{record.key}</span>
+        {record.value && <span className="config__set-value">{record.value}</span>}
+        <div>
+          <span className="config__last-item--help-text">{record.help}</span>
+          <span className="last-config-key">
+            {record.default && (
+              <span className="config__default-value">Default: {record.default}</span>
+            )}
+          </span>
+        </div>
+      </span>
+    );
+  }
+};

+ 195 - 0
desktop/core/src/desktop/js/apps/admin/Configuration/ConfigurationTab.test.tsx

@@ -0,0 +1,195 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// 'License'); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react';
+import { render, waitFor, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import Configuration from './ConfigurationTab';
+import { ConfigurationKey } from './ConfigurationKey';
+import { ConfigurationValue } from './ConfigurationValue';
+import ApiHelper from '../../../api/apiHelper';
+
+// Mock API call to fetch configuration data
+jest.mock('../../../api/apiHelper', () => ({
+  fetchHueConfigAsync: jest.fn()
+}));
+
+beforeEach(() => {
+  jest.clearAllMocks();
+  ApiHelper.fetchHueConfigAsync = jest.fn(() =>
+    Promise.resolve({
+      apps: [{ name: 'desktop', has_ui: true, display_name: 'Desktop' }],
+      config: [
+        {
+          help: 'Main configuration section',
+          key: 'desktop',
+          is_anonymous: false,
+          values: [
+            {
+              help: 'Example config help text',
+              key: 'example.config',
+              is_anonymous: false,
+              value: 'Example value'
+            },
+            {
+              help: 'Another config help text',
+              key: 'another.config',
+              is_anonymous: false,
+              value: 'Another value'
+            }
+          ]
+        }
+      ],
+      conf_dir: '/conf/directory'
+    })
+  );
+});
+
+describe('Configuration Component', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  test('Renders Configuration component with fetched data', async () => {
+    render(<Configuration />);
+
+    await waitFor(() => {
+      expect(screen.getByText(/Sections/i)).toBeInTheDocument();
+      expect(screen.getByText(/Desktop/i)).toBeInTheDocument();
+      expect(screen.getByText(/example\.config/i)).toBeInTheDocument();
+      expect(screen.getByText(/Example value/i)).toBeInTheDocument();
+      expect(ApiHelper.fetchHueConfigAsync).toHaveBeenCalled();
+    });
+  });
+
+  test('Filters configuration based on input text', async () => {
+    render(<Configuration />);
+
+    const filterInput = screen.getByPlaceholderText('Filter in desktop...');
+    fireEvent.change(filterInput, { target: { value: 'another' } });
+
+    await waitFor(() => {
+      expect(screen.queryByText('example.config')).not.toBeInTheDocument();
+      expect(screen.getByText('another.config')).toBeInTheDocument();
+    });
+  });
+
+  test('Displays message for empty configuration section', async () => {
+    (ApiHelper.fetchHueConfigAsync as jest.Mock).mockImplementation(() =>
+      Promise.resolve({
+        apps: [{ name: 'desktop', has_ui: true, display_name: 'Desktop' }],
+        config: [{ help: 'No help available.', key: 'desktop', is_anonymous: false, values: [] }],
+        conf_dir: '/conf/directory'
+      })
+    );
+
+    render(<Configuration />);
+
+    await waitFor(() => screen.getByText('Empty configuration section'));
+    expect(screen.getByText('Empty configuration section')).toBeInTheDocument();
+  });
+
+  describe('ConfigurationKey Component', () => {
+    test('If the record has further values in it, should display record key, helpText as tooltip', () => {
+      const mockRecord = {
+        help: 'This is help text',
+        key: 'config.key',
+        is_anonymous: false,
+        values: [
+          {
+            help: 'Example config help text',
+            key: 'example.config',
+            is_anonymous: false,
+            value: 'Example value'
+          },
+          {
+            help: 'Another config help text',
+            key: 'another.config',
+            is_anonymous: false,
+            value: 'Another value'
+          }
+        ]
+      };
+
+      const { getByText, container } = render(<ConfigurationKey record={mockRecord} />);
+
+      expect(getByText(/config.key/i)).toBeInTheDocument();
+      const tooltip = container.querySelector('.config__help-tooltip');
+      expect(tooltip).toBeInTheDocument();
+    });
+  });
+
+  test('If the record has no further values in it, verifies the entire config key state', () => {
+    const record = {
+      is_anonymous: false,
+      key: 'Last Config Key',
+      value: 'Some Value',
+      help: 'Help info',
+      default: 'Default Value'
+    };
+    const { getByText, container } = render(<ConfigurationKey record={record} />);
+
+    expect(getByText('Last Config Key')).toBeInTheDocument();
+    expect(getByText('Some Value')).toBeInTheDocument();
+    expect(getByText(/Help info/i)).toBeInTheDocument();
+    expect(getByText(/Default: Default Value/i)).toBeInTheDocument();
+
+    const tooltip = container.querySelector('.config__help-tooltip');
+    expect(tooltip).not.toBeInTheDocument();
+  });
+
+  test('renders "Default section" and help text as tooltip for anonymous records', () => {
+    const record = { is_anonymous: true, help: 'Help info', key: 'config.key' };
+    const { getByText, container } = render(<ConfigurationKey record={record} />);
+
+    expect(getByText(/Default section/i)).toBeInTheDocument();
+
+    const tooltip = container.querySelector('.config__help-tooltip');
+    expect(tooltip).toBeInTheDocument();
+  });
+
+  describe('ConfigurationValue Component', () => {
+    test('If the record has further values in it, renders nested configuration key and values correctly', () => {
+      const mockRecord = {
+        help: '',
+        key: 'parent.config',
+        is_anonymous: false,
+        values: [
+          {
+            help: 'child config help',
+            key: 'child.config',
+            is_anonymous: false,
+            value: 'child value'
+          }
+        ]
+      };
+
+      render(<ConfigurationValue record={mockRecord} />);
+
+      expect(screen.getByText('child.config')).toBeInTheDocument();
+      expect(screen.getByText('child value')).toBeInTheDocument();
+      expect(screen.queryByText('parent.config')).not.toBeInTheDocument();
+    });
+  });
+
+  test('If the record has no further values in it, renders nothing', () => {
+    const mockRecord = { help: '', key: 'empty.config', is_anonymous: false };
+
+    const { container } = render(<ConfigurationValue record={mockRecord} />);
+
+    expect(container).toBeEmptyDOMElement();
+  });
+});

+ 153 - 0
desktop/core/src/desktop/js/apps/admin/Configuration/ConfigurationTab.tsx

@@ -0,0 +1,153 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// 'License'); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React, { useState, useEffect, useMemo } from 'react';
+import { Spin, Alert } from 'antd';
+import { i18nReact } from '../../../utils/i18nReact';
+import AdminHeader from '../AdminHeader';
+import { ConfigurationValue } from './ConfigurationValue';
+import { ConfigurationKey } from './ConfigurationKey';
+import ApiHelper from '../../../api/apiHelper';
+import './Configuration.scss';
+
+interface App {
+  name: string;
+  has_ui: boolean;
+  display_name: string;
+}
+
+export interface AdminConfigValue {
+  help: string;
+  key: string;
+  is_anonymous: boolean;
+  values?: AdminConfigValue[];
+  default?: string;
+  value?: string;
+}
+
+interface Config {
+  help: string;
+  key: string;
+  is_anonymous: boolean;
+  values: AdminConfigValue[];
+}
+
+interface HueConfig {
+  apps: App[];
+  config: Config[];
+  conf_dir: string;
+}
+
+const Configuration: React.FC = (): JSX.Element => {
+  const { t } = i18nReact.useTranslation();
+  const [hueConfig, setHueConfig] = useState<HueConfig>();
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string>();
+  const [selectedApp, setSelectedApp] = useState<string>('desktop');
+  const [filter, setFilter] = useState<string>('');
+
+  useEffect(() => {
+    ApiHelper.fetchHueConfigAsync()
+      .then(data => {
+        setHueConfig(data);
+        if (data.apps.find(app => app.name === 'desktop')) {
+          setSelectedApp('desktop');
+        }
+      })
+      .catch(error => {
+        setError(error.message);
+      })
+      .finally(() => {
+        setLoading(false);
+      });
+  }, []);
+
+  const filterConfig = (
+    config: AdminConfigValue,
+    lowerCaseFilter: string
+  ): AdminConfigValue | undefined => {
+    //Filtering is done on Key and Help only
+    const keyMatches = config.key?.toLowerCase().includes(lowerCaseFilter);
+    const helpMatches = config.help?.toLowerCase().includes(lowerCaseFilter);
+
+    if (keyMatches || helpMatches) {
+      return config;
+    }
+
+    if (config.values) {
+      const filteredValues = config.values
+        .map(val => filterConfig(val, lowerCaseFilter))
+        .filter(Boolean) as AdminConfigValue[];
+      if (filteredValues.length) {
+        return { ...config, values: filteredValues };
+      }
+    }
+    return undefined;
+  };
+
+  const selectedConfig = useMemo(() => {
+    const filterSelectedApp = hueConfig?.config?.find(config => config.key === selectedApp);
+
+    return filterSelectedApp?.values
+      .map(config => filterConfig(config, filter.toLowerCase()))
+      .filter(Boolean) as Config[];
+  }, [hueConfig, filter, selectedApp]);
+
+  return (
+    <div className="config-component">
+      <Spin spinning={loading}>
+        {error && (
+          <Alert
+            message={`Error: ${error}`}
+            description="Error in displaying the Configuration!"
+            type="error"
+          />
+        )}
+
+        {!error && (
+          <>
+            <div className="config__section-header">Sections</div>
+            <AdminHeader
+              options={hueConfig?.apps.map(app => app.name) || []}
+              selectedValue={selectedApp}
+              onSelectChange={setSelectedApp}
+              filterValue={filter}
+              onFilterChange={setFilter}
+              placeholder={`Filter in ${selectedApp}...`}
+              configAddress={hueConfig?.conf_dir}
+            />
+            {selectedApp &&
+              selectedConfig &&
+              (selectedConfig.length > 0 ? (
+                <>
+                  {selectedConfig.map((record, index) => (
+                    <div key={index} className="config__main-item">
+                      <ConfigurationKey record={record} />
+                      <ConfigurationValue record={record} />
+                    </div>
+                  ))}
+                </>
+              ) : (
+                <i>{t('Empty configuration section')}</i>
+              ))}
+          </>
+        )}
+      </Spin>
+    </div>
+  );
+};
+
+export default Configuration;

+ 41 - 0
desktop/core/src/desktop/js/apps/admin/Configuration/ConfigurationValue.tsx

@@ -0,0 +1,41 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// 'License'); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react';
+import { AdminConfigValue as ParentConfigValue } from './ConfigurationTab';
+import './Configuration.scss';
+import { ConfigurationKey } from './ConfigurationKey';
+
+export const ConfigurationValue: React.FC<{ record: ParentConfigValue }> = ({ record }) => {
+  if (record.values && record.values.length > 0) {
+    return (
+      <>
+        {record.values.map((value, index) => (
+          <div
+            key={index}
+            className={
+              value.is_anonymous ? 'config__default-section--help-text' : 'config__child-item'
+            }
+          >
+            <ConfigurationKey record={value} />
+            <ConfigurationValue record={value} />
+          </div>
+        ))}
+      </>
+    );
+  }
+  return <></>;
+};

+ 1 - 19
desktop/core/src/desktop/js/apps/admin/Metrics/Metrics.scss

@@ -18,7 +18,7 @@
 
 
 .metrics-component.antd.cuix {
 .metrics-component.antd.cuix {
   background-color: $fluidx-gray-100;
   background-color: $fluidx-gray-100;
-  padding: 0 24px 24px 24px;
+  padding: 24px;
 
 
   .metrics-heading {
   .metrics-heading {
     font-size: $font-size-base;
     font-size: $font-size-base;
@@ -26,16 +26,6 @@
     color: $fluidx-gray-900;
     color: $fluidx-gray-900;
   }
   }
 
 
-  .metrics-filter {
-    margin: $font-size-sm;
-    width: 30%;
-
-    input {
-      box-shadow: none;
-      -webkit-box-shadow: none;
-    }
-  }
-
   .metrics-table {
   .metrics-table {
     th {
     th {
       width: 30%;
       width: 30%;
@@ -44,12 +34,4 @@
 
 
     margin-bottom: $font-size-base;
     margin-bottom: $font-size-base;
   }
   }
-
-  .metrics-select {
-    border: 1px solid $fluidx-gray-600;
-    border-radius: $border-radius-base;
-    background-color: $fluidx-white;
-    min-width: 200px;
-    height: 32px;
-  }
 }
 }

+ 4 - 5
desktop/core/src/desktop/js/apps/admin/Metrics/Metrics.test.tsx → desktop/core/src/desktop/js/apps/admin/Metrics/MetricsTab.test.tsx

@@ -17,7 +17,7 @@
 import React from 'react';
 import React from 'react';
 import { render, waitFor, screen, fireEvent } from '@testing-library/react';
 import { render, waitFor, screen, fireEvent } from '@testing-library/react';
 import '@testing-library/jest-dom';
 import '@testing-library/jest-dom';
-import Metrics from './Metrics';
+import Metrics from './MetricsTab';
 
 
 // Mock the API call to return sample metrics data
 // Mock the API call to return sample metrics data
 jest.mock('api/utils', () => ({
 jest.mock('api/utils', () => ({
@@ -73,12 +73,12 @@ describe('Metrics', () => {
     });
     });
   });
   });
 
 
-  test('selecting a specific metric from the dropdown filters the data using click events', async () => {
+  test('Selecting a specific metric from the dropdown filters the data using click events', async () => {
     render(<Metrics />);
     render(<Metrics />);
 
 
     await waitFor(() => screen.getByText('queries.number'));
     await waitFor(() => screen.getByText('queries.number'));
 
 
-    const select = screen.getByTestId('metric-select').firstElementChild;
+    const select = screen.getByTestId('admin-header--select').firstElementChild;
     if (select) {
     if (select) {
       fireEvent.mouseDown(select);
       fireEvent.mouseDown(select);
     }
     }
@@ -89,7 +89,6 @@ describe('Metrics', () => {
     if (secondOption) {
     if (secondOption) {
       fireEvent.click(secondOption);
       fireEvent.click(secondOption);
       await waitFor(() => {
       await waitFor(() => {
-        // const headings = screen.queryAllByRole('heading', { level: 4 });
         const headings = screen.queryAllByText(
         const headings = screen.queryAllByText(
           (_, element) =>
           (_, element) =>
             element?.tagName.toLowerCase() === 'span' && element?.className === 'metrics-heading'
             element?.tagName.toLowerCase() === 'span' && element?.className === 'metrics-heading'
@@ -99,7 +98,7 @@ describe('Metrics', () => {
     }
     }
   });
   });
 
 
-  test('ensuring metrics starting with auth, multiprocessing and python.gc are not displayed', async () => {
+  test('Ensuring metrics starting with auth, multiprocessing and python.gc are not displayed', async () => {
     jest.clearAllMocks();
     jest.clearAllMocks();
     jest.mock('api/utils', () => ({
     jest.mock('api/utils', () => ({
       get: jest.fn(() =>
       get: jest.fn(() =>

+ 24 - 39
desktop/core/src/desktop/js/apps/admin/Metrics/Metrics.tsx → desktop/core/src/desktop/js/apps/admin/Metrics/MetricsTab.tsx

@@ -14,15 +14,14 @@
 // See the License for the specific language governing permissions and
 // See the License for the specific language governing permissions and
 // limitations under the License.
 // limitations under the License.
 
 
-import React, { useState, useEffect, useRef } from 'react';
+import React, { useState, useEffect } from 'react';
 import MetricsTable, { MetricsResponse, MetricsTableProps } from './MetricsTable';
 import MetricsTable, { MetricsResponse, MetricsTableProps } from './MetricsTable';
-import { Spin, Input, Select, Alert } from 'antd';
+import { Spin, Alert } from 'antd';
 import { get } from '../../../api/utils';
 import { get } from '../../../api/utils';
-import { SearchOutlined } from '@ant-design/icons';
 import { i18nReact } from '../../../utils/i18nReact';
 import { i18nReact } from '../../../utils/i18nReact';
-import './Metrics.scss';
+import AdminHeader from '../AdminHeader';
 
 
-const { Option } = Select;
+import './Metrics.scss';
 
 
 const Metrics: React.FC = (): JSX.Element => {
 const Metrics: React.FC = (): JSX.Element => {
   const [metrics, setMetrics] = useState<MetricsResponse>();
   const [metrics, setMetrics] = useState<MetricsResponse>();
@@ -30,10 +29,9 @@ const Metrics: React.FC = (): JSX.Element => {
   const [loading, setLoading] = useState(true);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string>();
   const [error, setError] = useState<string>();
   const [searchQuery, setSearchQuery] = useState<string>('');
   const [searchQuery, setSearchQuery] = useState<string>('');
-  const [selectedMetric, setSelectedMetric] = useState<string>('');
+  const [selectedMetric, setSelectedMetric] = useState<string>('All');
   const [showAllTables, setShowAllTables] = useState(true);
   const [showAllTables, setShowAllTables] = useState(true);
   const [filteredMetricsData, setFilteredMetricsData] = useState<MetricsTableProps[]>([]);
   const [filteredMetricsData, setFilteredMetricsData] = useState<MetricsTableProps[]>([]);
-  const dropdownRef = useRef(null);
 
 
   useEffect(() => {
   useEffect(() => {
     const fetchData = async () => {
     const fetchData = async () => {
@@ -62,9 +60,14 @@ const Metrics: React.FC = (): JSX.Element => {
       return;
       return;
     }
     }
 
 
-    const filteredData = parseMetricsData(metrics).filter(tableData =>
-      tableData.dataSource.some(data => data.name.toLowerCase().includes(searchQuery.toLowerCase()))
-    );
+    const filteredData = parseMetricsData(metrics)
+      .map(tableData => ({
+        ...tableData,
+        dataSource: tableData.dataSource.filter(data =>
+          data.name.toLowerCase().includes(searchQuery.toLowerCase())
+        )
+      }))
+      .filter(tableData => tableData.dataSource.length > 0);
 
 
     setFilteredMetricsData(filteredData);
     setFilteredMetricsData(filteredData);
   }, [searchQuery, metrics]);
   }, [searchQuery, metrics]);
@@ -80,11 +83,11 @@ const Metrics: React.FC = (): JSX.Element => {
   };
   };
   const handleMetricChange = (value: string) => {
   const handleMetricChange = (value: string) => {
     setSelectedMetric(value);
     setSelectedMetric(value);
-    setShowAllTables(value === '');
+    setShowAllTables(value === 'All');
   };
   };
 
 
-  const handleFilterInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
-    setSearchQuery(e.target.value);
+  const handleFilterInputChange = (filterValue: string) => {
+    setSearchQuery(filterValue);
   };
   };
 
 
   const { t } = i18nReact.useTranslation();
   const { t } = i18nReact.useTranslation();
@@ -93,32 +96,14 @@ const Metrics: React.FC = (): JSX.Element => {
     <div className="cuix antd metrics-component">
     <div className="cuix antd metrics-component">
       <Spin spinning={loading}>
       <Spin spinning={loading}>
         {!error && (
         {!error && (
-          <>
-            <Select
-              className="metrics-select"
-              //to make sure antd class gets applied
-              getPopupContainer={triggerNode => triggerNode.parentElement}
-              ref={dropdownRef}
-              value={selectedMetric}
-              onChange={handleMetricChange}
-              data-testid="metric-select"
-            >
-              <Option value="">All</Option>
-              {filteredKeys.map(key => (
-                <Option key={key} value={key}>
-                  {key}
-                </Option>
-              ))}
-            </Select>
-
-            <Input
-              className="metrics-filter"
-              placeholder={t('Filter metrics...')}
-              value={searchQuery}
-              onChange={handleFilterInputChange}
-              prefix={<SearchOutlined />}
-            />
-          </>
+          <AdminHeader
+            options={['All', ...filteredKeys]}
+            selectedValue={selectedMetric}
+            onSelectChange={handleMetricChange}
+            filterValue={searchQuery}
+            onFilterChange={handleFilterInputChange}
+            placeholder={t('Filter metrics...')}
+          />
         )}
         )}
 
 
         {error && (
         {error && (

+ 0 - 1
desktop/core/src/desktop/js/ko/ko.all.js

@@ -173,7 +173,6 @@ import 'ko/components/ko.shareGistModal';
 import 'ko/components/ko.sqlColumnsTable';
 import 'ko/components/ko.sqlColumnsTable';
 
 
 // TODO: Move to about app when it has it's own webpack entry
 // TODO: Move to about app when it has it's own webpack entry
-import 'apps/about/components/ko.hueConfigTree';
 import 'apps/about/components/ko.connectorsConfig';
 import 'apps/about/components/ko.connectorsConfig';
 
 
 import 'ko/extenders/ko.maxLength';
 import 'ko/extenders/ko.maxLength';

+ 4 - 1
desktop/core/src/desktop/js/reactComponents/imports.js

@@ -11,7 +11,10 @@ export async function loadComponent(name) {
       return (await import('../apps/storageBrowser/StorageBrowserPage/StorageBrowserPage')).default;
       return (await import('../apps/storageBrowser/StorageBrowserPage/StorageBrowserPage')).default;
 
 
     case 'Metrics':
     case 'Metrics':
-      return (await import('../apps/admin/Metrics/Metrics')).default;
+      return (await import('../apps/admin/Metrics/MetricsTab')).default;
+
+    case 'Configuration':
+      return (await import('../apps/admin/Configuration/ConfigurationTab')).default;
 
 
     // Application global components here
     // Application global components here
     case 'AppBanner':
     case 'AppBanner':

+ 7 - 24
desktop/core/src/desktop/templates/dump_config.mako

@@ -14,33 +14,16 @@
 ## See the License for the specific language governing permissions and
 ## See the License for the specific language governing permissions and
 ## limitations under the License.
 ## limitations under the License.
 
 
-<%!
-import logging
-import sys
-
-from desktop.views import commonheader, commonfooter
-
-
-LOG = logging.getLogger()
-%>
-
 <%namespace name="layout" file="about_layout.mako" />
 <%namespace name="layout" file="about_layout.mako" />
 
 
 ${ layout.menubar(section='dump_config') }
 ${ layout.menubar(section='dump_config') }
 
 
-<style type="text/css">
-  .card-heading .pull-right {
-    font-size: 12px;
-    font-weight: normal;
-  }
-</style>
-
-<div id="aboutConfiguration">
-  <!-- ko component: { name: 'hue-config-tree' } --><!-- /ko -->
-</div>
-
 <script type="text/javascript">
 <script type="text/javascript">
-  $(document).ready(function () {
-    ko.applyBindings({}, $('#aboutConfiguration')[0]);
-  });
+  (function () {
+    window.createReactComponents('#Configuration');
+  })();
 </script>
 </script>
+
+<div id="Configuration">
+<Configuration class='antd cuix' data-reactcomponent='Configuration'></Configuration>
+</div>