Browse Source

[ui-metrics] Converted the Metrics page within Admin Server in ReactJS (#3707)

* [ui-metrics] Converted the Metrics page within Admin Server in ReactJS

* Fixing issues for removing the errors

* Fixing the linting issues

* metricsNew

* Fixing lints in MetricsTable

* Fixing the tests-unnecessary changes

* Fixing the unit test, missing return type errors, any return type error

* Not hardcoding the metrics keys and generating dynamically

* WIP

* Updated the test for the review

* WIP

* fix unit test

Change-Id: I641dfe1152e184e8501dc718e49f4e038bbe69c4

* Fixed unit test for select with proper linting

* Fixing the style issues

* replace click with mouseDown for clicking the dropDown

* Filtering the metrics and removing python.gc, multiprocessing, auth starting metrics

* Fixing the linting issues

* Removing matchMedia as it is not required anymore

* Remove the unneccessary comments

* Proper heading to h4 tag

* remove the snake_case and add i18n

* Fixing the linting issues

* Fixing based on Bjorn's review comments

* Updating based on Tabriz's PR review comments

* Fixing linting issues

* Changing the metrics only to the required fields in metricsTable

* Fixing style issues

* Removing the shadow within the input filter

* Filtering done inside useEffect hook only and removed from elsewhere after saving state

* Revertung box shadow changes

* [ui-metrics] Converted the Metrics page within Admin Server in ReactJS

* Fixing issues for removing the errors

* Fixing the linting issues

* metricsNew

* Fixing lints in MetricsTable

* Fixing the tests-unnecessary changes

* Fixing the unit test, missing return type errors, any return type error

* Not hardcoding the metrics keys and generating dynamically

* WIP

* Updated the test for the review

* WIP

* fix unit test

Change-Id: I641dfe1152e184e8501dc718e49f4e038bbe69c4

* Fixed unit test for select with proper linting

* Fixing the style issues

* replace click with mouseDown for clicking the dropDown

* Filtering the metrics and removing python.gc, multiprocessing, auth starting metrics

* Fixing the linting issues

* Removing matchMedia as it is not required anymore

* Remove the unneccessary comments

* Proper heading to h4 tag

* remove the snake_case and add i18n

* Fixing the linting issues

* Fixing based on Bjorn's review comments

* Updating based on Tabriz's PR review comments

* Fixing linting issues

* Changing the metrics only to the required fields in metricsTable

* Fixing style issues

* Removing the shadow within the input filter

* Filtering done inside useEffect hook only and removed from elsewhere after saving state

* Revertung box shadow changes

* grey line removal WIP

* Box shadow final removal

* fixing indentation

* Removing unneccesary comments

---------

Co-authored-by: tabraiz <tabraiz@cloudera.com>
Ananya_Agarwal 1 năm trước cách đây
mục cha
commit
bdeccbdd33

+ 52 - 0
desktop/core/src/desktop/js/reactComponents/MetricsComponent/MetricsComponent.scss

@@ -0,0 +1,52 @@
+// 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';
+
+.metrics-component {
+  background-color: $fluidx-gray-100;
+  padding: 0 24px 24px 24px;
+
+  .metrics-heading {
+    color: $fluidx-gray-700;
+    padding-left: $font-size-lg;
+    font-size: $fluidx-heading-h4-size;
+    line-height: $fluidx-heading-h4-line-height;
+    font-weight: $fluidx-heading-h4-weight;
+  }
+
+  .metrics-filter {
+    margin: $font-size-sm;
+    width: 30%;
+
+    input {
+      box-shadow: none;
+      -webkit-box-shadow: none;
+    }
+  }
+
+  .metrics-table th {
+    width: 30%;
+  }
+
+  .metrics-select {
+    border: 1px solid $fluidx-gray-600;
+    border-radius: $border-radius-base;
+    background-color: $fluidx-white;
+    min-width: 200px;
+    height: 32px;
+  }
+}

+ 99 - 0
desktop/core/src/desktop/js/reactComponents/MetricsComponent/MetricsComponent.test.tsx

@@ -0,0 +1,99 @@
+// 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 MetricsComponent from './MetricsComponent';
+
+// Mock the API call to return sample metrics data
+jest.mock('api/utils', () => ({
+  get: jest.fn(() =>
+    Promise.resolve({
+      metric: {
+        'queries.number': { value: 10 },
+        'requests.active': { count: 5 },
+        'requests.exceptions': { count: 2 },
+        'requests.response-time': {
+          '15m_rate': 20,
+          '1m_rate': 15,
+          '5m_rate': 18,
+          '75_percentile': 50,
+          '95_percentile': 60,
+          '999_percentile': 70,
+          '99_percentile': 55,
+          avg: 25,
+          count: 100,
+          max: 30,
+          mean_rate: 22,
+          min: 20,
+          std_dev: 5,
+          sum: 2500
+        },
+        'threads.daemon': { value: 3 },
+        'threads.total': { value: 6 },
+        users: { value: 50 },
+        'users.active': { value: 30 },
+        'users.active.total': { value: 40 }
+      }
+    })
+  )
+}));
+
+describe('MetricsComponent', () => {
+  // Test for filtering metrics based on input
+  test('Filtering metrics based on name column value', async () => {
+    render(<MetricsComponent />);
+
+    const filterInput = screen.getByPlaceholderText('Filter metrics...');
+    fireEvent.change(filterInput, { target: { value: 'value' } });
+
+    await waitFor(() => {
+      expect(screen.getByText('queries.number')).toBeInTheDocument();
+      expect(screen.getByText('threads.daemon')).toBeInTheDocument();
+      expect(screen.getByText('threads.total')).toBeInTheDocument();
+      expect(screen.getByText('users')).toBeInTheDocument();
+      expect(screen.getByText('users.active')).toBeInTheDocument();
+      expect(screen.getByText('users.active.total')).toBeInTheDocument();
+      expect(screen.queryByText('requests.active')).not.toBeInTheDocument();
+      expect(screen.queryByText('requests.exceptions')).toBeNull();
+      expect(screen.queryByText('requests.response-time')).toBeNull();
+    });
+  });
+
+  // Test for selecting a specific metric from the dropdown
+  test('selecting a specific metric from the dropdown filters the data using click events', async () => {
+    render(<MetricsComponent />);
+
+    await waitFor(() => screen.getByText('queries.number'));
+
+    const select = screen.getByTestId('metric-select').firstElementChild;
+    if (select) {
+      fireEvent.mouseDown(select);
+    }
+
+    const dropdown = document.querySelector('.ant-select');
+
+    const secondOption = dropdown?.querySelectorAll('.ant-select-item')[1];
+    if (secondOption) {
+      fireEvent.click(secondOption);
+      await waitFor(() => {
+        const headings = screen.queryAllByRole('heading', { level: 4 });
+        expect(headings).toHaveLength(1);
+      });
+    }
+  });
+});

+ 142 - 0
desktop/core/src/desktop/js/reactComponents/MetricsComponent/MetricsComponent.tsx

@@ -0,0 +1,142 @@
+// 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, useRef } from 'react';
+import MetricsTable, { MetricsResponse } from './MetricsTable';
+import { Spin, Input, Select, Alert } from 'antd';
+import { get } from 'api/utils';
+import { SearchOutlined } from '@ant-design/icons';
+import './MetricsComponent.scss';
+
+const { Option } = Select;
+
+const MetricsComponent: React.FC = (): JSX.Element => {
+  const [metrics, setMetrics] = useState<MetricsResponse>();
+  const [filteredKeys, setFilteredKeys] = useState<string[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string>();
+  const [searchQuery, setSearchQuery] = useState<string>('');
+  const [selectedMetric, setSelectedMetric] = useState<string>('');
+  const [showAllTables, setShowAllTables] = useState(true);
+  const [filteredMetricsData, setFilteredMetricsData] = useState<MetricsData[]>([]);
+  const dropdownRef = useRef(null);
+
+  useEffect(() => {
+    const fetchData = async () => {
+      try {
+        const response = await get<MetricsResponse>('/desktop/metrics/', { format: 'json' });
+        setMetrics(response);
+        const keys = Object.keys(response.metric).filter(
+          key =>
+            !key.startsWith('auth') &&
+            !key.startsWith('multiprocessing') &&
+            !key.startsWith('python.gc')
+        );
+        setFilteredKeys(keys);
+      } catch (error) {
+        setError(error.message);
+      } finally {
+        setLoading(false);
+      }
+    };
+
+    fetchData();
+  }, []);
+
+  useEffect(() => {
+    if (!metrics) {
+      return;
+    }
+
+    const filteredData = parseMetricsData(metrics).filter(tableData =>
+      tableData.dataSource.some(data => data.name.toLowerCase().includes(searchQuery.toLowerCase()))
+    );
+
+    setFilteredMetricsData(filteredData);
+  }, [searchQuery, metrics]);
+
+  const parseMetricsData = (data: MetricsResponse) => {
+    return filteredKeys.map(key => ({
+      caption: key,
+      dataSource: Object.keys(data.metric[key]).map(subKey => ({
+        name: subKey,
+        value: data.metric[key][subKey]
+      }))
+    }));
+  };
+  const handleMetricChange = (value: string) => {
+    setSelectedMetric(value);
+    setShowAllTables(value === '');
+  };
+
+  const handleFilterInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    setSearchQuery(e.target.value);
+  };
+
+  return (
+    <div className="cuix antd metrics-component">
+      <Spin spinning={loading}>
+        {!error && (
+          <>
+            <Input
+              className="metrics-filter"
+              placeholder="Filter metrics..."
+              value={searchQuery}
+              onChange={handleFilterInputChange}
+              prefix={<SearchOutlined />}
+            />
+
+            <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>
+          </>
+        )}
+
+        {error && (
+          <Alert
+            message={`Error: ${error}`}
+            description="Error in displaying the Metrics!"
+            type="error"
+          />
+        )}
+
+        {!error &&
+          filteredMetricsData.map((tableData, index) => (
+            <div key={index}>
+              {(showAllTables || selectedMetric === tableData.caption) && (
+                <MetricsTable caption={tableData.caption} dataSource={tableData.dataSource} />
+              )}
+            </div>
+          ))}
+      </Spin>
+    </div>
+  );
+};
+
+export default MetricsComponent;

+ 130 - 0
desktop/core/src/desktop/js/reactComponents/MetricsComponent/MetricsTable.tsx

@@ -0,0 +1,130 @@
+// 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 Table from 'cuix/dist/components/Table/Table';
+import type { ColumnType } from 'antd/es/table';
+import './MetricsComponent.scss';
+import I18n from 'utils/i18n';
+
+interface MetricsValue {
+  value: number;
+}
+interface MetricsTime {
+  '1m_rate': number;
+  '5m_rate': number;
+  '15m_rate': number;
+  '75_percentile': number;
+  '95_percentile': number;
+  '99_percentile': number;
+  '999_percentile': number;
+  avg: number;
+  count: number;
+  max: number;
+  mean_rate: number;
+  min: number;
+  std_dev: number;
+  sum: number;
+}
+
+interface MetricsCount {
+  count: number;
+}
+export interface MetricsResponse {
+  metric: {
+    'auth.ldap.auth-time': MetricsTime;
+    'auth.oauth.auth-time': MetricsTime;
+    'auth.pam.auth-time': MetricsTime;
+    'auth.saml2.auth-time': MetricsTime;
+    'auth.spnego.auth-time': MetricsTime;
+    'multiprocessing.processes.daemon': MetricsValue;
+    'multiprocessing.processes.total': MetricsValue;
+    'python.gc.generation.0': MetricsValue;
+    'python.gc.generation.1': MetricsValue;
+    'python.gc.generation.2': MetricsValue;
+    'python.gc.objects': MetricsValue;
+    'queries.number': MetricsValue;
+    'requests.active': MetricsCount;
+    'requests.exceptions': MetricsCount;
+    'requests.response-time': MetricsTime;
+    'threads.daemon': MetricsValue;
+    'threads.total': MetricsValue;
+    users: MetricsValue;
+    'users.active': MetricsValue;
+    'users.active.total': MetricsValue;
+    timestamp: string;
+  };
+}
+
+interface DataSourceItem {
+  name: string;
+  value: number | MetricsTime;
+}
+
+interface MetricsTableProps {
+  caption: string;
+  dataSource: DataSourceItem[];
+}
+const metricLabels: { [key: string]: string } = {
+  'queries.number': I18n('Number of Queries'),
+  'requests.active': I18n('Active Requests'),
+  'requests.exceptions': I18n('Request Exceptions'),
+  'requests.response-time': I18n('Request Response Time'),
+  'threads.daemon': I18n('Daemon Threads'),
+  'threads.total': I18n('Total Threads'),
+  users: I18n('Users'),
+  'users.active': I18n('Active Users'),
+  'users.active.total': I18n('Total Active Users')
+};
+
+const transformMetricNames = (dataSource: DataSourceItem[]): DataSourceItem[] =>
+  dataSource.map(item => ({
+    ...item,
+    name: metricLabels[item.name] || item.name
+  }));
+
+const MetricsTable: React.FC<MetricsTableProps> = ({ caption, dataSource }) => {
+  const transformedDataSource = transformMetricNames(dataSource);
+
+  const metricsColumns: ColumnType<DataSourceItem>[] = [
+    {
+      title: 'Name',
+      dataIndex: 'name',
+      key: 'name'
+    },
+    {
+      title: 'Value',
+      dataIndex: 'value',
+      key: 'value',
+      render: value => (typeof value === 'number' ? value : JSON.stringify(value))
+    }
+  ];
+
+  return (
+    <>
+      <h4 className="metrics-heading">{caption}</h4>
+      <Table
+        className="metrics-table"
+        dataSource={transformedDataSource}
+        rowKey="name"
+        columns={metricsColumns}
+        pagination={false}
+      />
+    </>
+  );
+};
+
+export default MetricsTable;

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

@@ -1,4 +1,4 @@
-// ADD NEW RACT COMPONENTS HERE
+// ADD NEW REACT COMPONENTS HERE
 // We need a way to match an imported module with a component name
 // so we handle the imports dynamically for that reason.
 export async function loadComponent(name) {
@@ -10,6 +10,9 @@ export async function loadComponent(name) {
     case 'StorageBrowserPage':
       return (await import('../apps/storageBrowser/StorageBrowserPage/StorageBrowserPage')).default;
 
+    case 'MetricsComponent':
+      return (await import('./MetricsComponent/MetricsComponent')).default;
+
     // Application global components here
     case 'AppBanner':
       return (await import('./AppBanner/AppBanner')).default;

+ 7 - 69
desktop/core/src/desktop/templates/metrics.mako

@@ -101,78 +101,16 @@ ${ commonheader(_('Metrics'), "about", user, request) | n,unicode }
 
 ${layout.menubar(section='metrics')}
 
-<div id="metricsComponents" class="container-fluid">
-  <div class="card card-small margin-top-10">
-    <!-- ko if: metrics() -->
-    <div data-bind="dockable: { scrollable: ${ MAIN_SCROLLABLE }, jumpCorrection: 0,topSnap: '${ TOP_SNAP }', triggerAdjust: ${ is_embeddable and "0" or "106" }}">
-      <ul class="nav nav-pills">
-        <li data-bind="css: { 'active': $root.selectedMetric() === 'All' }">
-          <a href="javascript:void(0)" data-bind="text: 'All', click: function(){ $root.selectedMetric('All') }"></a>
-        </li>
-        <!-- ko foreach: metricsKeys -->
-        <!-- ko ifnot: $root.isUnusedMetric($data)-->
-        <li data-bind="css: { 'active': $root.selectedMetric() === $data }">
-          <a href="javascript:void(0)" data-bind="text: $data, click: function(){ $root.selectedMetric($data) }"></a>
-        </li>
-        <!-- /ko -->
-        <!-- /ko -->
-      </ul>
-      <input type="text" data-bind="clearable: metricsFilter, valueUpdate: 'afterkeydown'"
-          class="input-xlarge pull-right margin-bottom-10" placeholder="${_('Filter metrics...')}">
-    </div>
+<script type="text/javascript">
+  (function () {
+    window.createReactComponents('#MetricsComponent');
+  })();
+</script>
 
-    <div class="margin-top-10">
-      <!-- ko if: $root.selectedMetric() === 'All' && $root.isMasterEmpty()-->
-      <table class="table table-condensed">
-        <thead>
-          <tr>
-            <th width="30%">${ _('Name') }</th>
-            <th>${ _('Value') }</th>
-          </tr>
-        </thead>
-        <tfoot>
-          <tr>
-              <td colspan="2">${ _('There are no metrics matching your filter') }</td>
-          </tr>
-        </tfoot>
-      </table>
-      <!-- /ko -->
-      <div data-bind="foreach: {data: Object.keys($root.filteredMetrics()).sort(), as: '_masterkey'}">
-      <!-- ko if: ($root.selectedMetric() === 'All' && $root.filteredMetrics()[_masterkey]) || $root.selectedMetric() === _masterkey-->
-      <!-- ko ifnot: $root.isUnusedMetric(_masterkey)-->
-      <h4 data-bind="text: _masterkey"></h4>
-      <table class="table table-condensed">
-        <thead>
-          <tr>
-            <th width="30%">${ _('Name') }</th>
-            <th>${ _('Value') }</th>
-          </tr>
-        </thead>
-        <!-- ko if: $root.filteredMetrics()[_masterkey] -->
-        <tbody data-bind="foreach: {'data': Object.keys($root.filteredMetrics()[_masterkey])}">
-          <tr>
-            <td data-bind="text: $data"></td>
-            <td data-bind="text: $root.filteredMetrics()[_masterkey][$data]"></td>
-          </tr>
-        </tbody>
-        <!-- /ko -->
-        <!-- ko ifnot: $root.filteredMetrics()[_masterkey] -->
-        <tfoot>
-          <tr>
-            <td colspan="2">${ _('There are no metrics matching your filter') }</td>
-          </tr>
-          </tfoot>
-        <!-- /ko -->
-        </table>
-        <!-- /ko -->
-        <!-- /ko -->
-      </div>
-    </div>
-  <!-- /ko -->
-  </div>
+<div id="MetricsComponent">
+<MetricsComponent data-reactcomponent='MetricsComponent'></MetricsComponent>
 </div>
 
-
 %if not is_embeddable:
 ${ commonfooter(request, messages) | n,unicode }
 %endif