diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/db.json b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/db.json index f1d5dc367033..c6202027676e 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/db.json +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/db.json @@ -1923,7 +1923,7 @@ "actualReplicaCount": 2, "replicaDeltaCount": 1, "reason": null, - "keys": 1, + "keys": 4, "pipelineID": "a10ffab6-8ed5-414a-aaf5-79890ff3e8a1", "replicas": [ { @@ -1997,7 +1997,7 @@ "actualReplicaCount": 2, "replicaDeltaCount": 2, "reason": null, - "keys": 1, + "keys": 3, "pipelineID": "a10ffab6-8ed5-414a-aaf5-79890ff3e8a1", "replicas": [ { @@ -2071,7 +2071,7 @@ "actualReplicaCount": 2, "replicaDeltaCount": 2, "reason": null, - "keys": 1, + "keys": 2, "pipelineID": "a10ffab6-8ed5-414a-aaf5-79890ff3e8a1", "replicas": [ { @@ -2108,7 +2108,7 @@ "actualReplicaCount": 2, "replicaDeltaCount": 2, "reason": null, - "keys": 1, + "keys": 5, "pipelineID": "a10ffab6-8ed5-414a-aaf5-79890ff3e8a1", "replicas": [ { @@ -2145,7 +2145,7 @@ "actualReplicaCount": 2, "replicaDeltaCount": 2, "reason": null, - "keys": 1, + "keys": 3, "pipelineID": "a10ffab6-8ed5-414a-aaf5-79890ff3e8a1", "replicas": [ { @@ -2182,7 +2182,7 @@ "actualReplicaCount": 2, "replicaDeltaCount": 2, "reason": null, - "keys": 1, + "keys": 6, "pipelineID": "a10ffab6-8ed5-414a-aaf5-79890ff3e8a2", "replicas": [ { @@ -2219,7 +2219,7 @@ "actualReplicaCount": 2, "replicaDeltaCount": 2, "reason": null, - "keys": 1, + "keys": 2, "pipelineID": "a10ffab6-8ed5-414a-aaf5-79890ff3e8a3", "replicas": [ { diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/constants/breadcrumbs.constants.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/constants/breadcrumbs.constants.tsx index 88953a5ed7d3..bc81e86dcb3c 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/constants/breadcrumbs.constants.tsx +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/constants/breadcrumbs.constants.tsx @@ -27,6 +27,7 @@ export const breadcrumbNameMap: IBreadcrumbNameMap = { '/Datanodes': 'Datanodes', '/Pipelines': 'Pipelines', '/MissingContainers': 'Missing Containers', + '/Containers': 'Containers', '/Insights': 'Insights', '/DiskUsage': 'Disk Usage', '/Heatmap': 'Heatmap', diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/tables/bucketsTable.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/tables/bucketsTable.tsx index b26ae251f95d..0060177795b2 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/tables/bucketsTable.tsx +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/tables/bucketsTable.tsx @@ -255,7 +255,7 @@ const BucketsTable: React.FC = ({ dataSource={getFilteredData(data)} columns={filterSelectedColumns()} loading={loading} - rowKey='volume' + rowKey={(record: Bucket) => `${record.volumeName}/${record.name}`} pagination={paginationConfig} scroll={{ x: 'max-content', scrollToFirstRowOnChange: true }} locale={{ filterTitle: '' }} diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/tables/containersTable.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/tables/containersTable.tsx new file mode 100644 index 000000000000..1bb1b5456b5d --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/tables/containersTable.tsx @@ -0,0 +1,259 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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, { useRef } from 'react'; +import filesize from 'filesize'; +import { AxiosError } from 'axios'; +import { Popover, Table } from 'antd'; +import { + ColumnsType, + TablePaginationConfig +} from 'antd/es/table'; +import { NodeIndexOutlined } from '@ant-design/icons'; + +import { getFormattedTime } from '@/v2/utils/momentUtils'; +import { showDataFetchError } from '@/utils/common'; +import { AxiosGetHelper } from '@/utils/axiosRequestHelper'; +import { + Container, ContainerKeysResponse, ContainerReplica, + ContainerTableProps, + ExpandedRowState, KeyResponse +} from '@/v2/types/container.types'; + +const size = filesize.partial({ standard: 'iec' }); + +export const COLUMNS: ColumnsType = [ + { + title: 'Container ID', + dataIndex: 'containerID', + key: 'containerID', + sorter: (a: Container, b: Container) => a.containerID - b.containerID + }, + { + title: 'No. of Keys', + dataIndex: 'keys', + key: 'keys', + sorter: (a: Container, b: Container) => a.keys - b.keys + }, + { + title: 'Actual/Expected Replica(s)', + dataIndex: 'expectedReplicaCount', + key: 'expectedReplicaCount', + render: (expectedReplicaCount: number, record: Container) => { + const actualReplicaCount = record.actualReplicaCount; + return ( + + {actualReplicaCount} / {expectedReplicaCount} + + ); + } + }, + { + title: 'Datanodes', + dataIndex: 'replicas', + key: 'replicas', + render: (replicas: ContainerReplica[]) => { + const renderDatanodes = (replicas: ContainerReplica[]) => { + return replicas?.map((replica: any, idx: number) => ( +
+ {replica.datanodeHost} +
+ )) + } + + return ( + + {replicas.length} datanodes + + ) + } + }, + { + title: 'Pipeline ID', + dataIndex: 'pipelineID', + key: 'pipelineID' + }, + { + title: 'Unhealthy Since', + dataIndex: 'unhealthySince', + key: 'unhealthySince', + render: (unhealthySince: number) => getFormattedTime(unhealthySince, 'lll'), + sorter: (a: Container, b: Container) => a.unhealthySince - b.unhealthySince + } +]; + +const KEY_TABLE_COLUMNS: ColumnsType = [ + { + title: 'Volume', + dataIndex: 'Volume', + key: 'Volume' + }, + { + title: 'Bucket', + dataIndex: 'Bucket', + key: 'Bucket' + }, + { + title: 'Key', + dataIndex: 'Key', + key: 'Key' + }, + { + title: 'Size', + dataIndex: 'DataSize', + key: 'DataSize', + render: (dataSize: number) =>
{size(dataSize)}
+ }, + { + title: 'Date Created', + dataIndex: 'CreationTime', + key: 'CreationTime', + render: (date: string) => getFormattedTime(date, 'lll') + }, + { + title: 'Date Modified', + dataIndex: 'ModificationTime', + key: 'ModificationTime', + render: (date: string) => getFormattedTime(date, 'lll') + }, + { + title: 'Path', + dataIndex: 'CompletePath', + key: 'path' + } +]; + +const ContainerTable: React.FC = ({ + data, + loading, + selectedColumns, + expandedRow, + expandedRowSetter, + searchColumn = 'containerID', + searchTerm = '' +}) => { + + const cancelSignal = useRef(); + + function filterSelectedColumns() { + const columnKeys = selectedColumns.map((column) => column.value); + return COLUMNS.filter( + (column) => columnKeys.indexOf(column.key as string) >= 0 + ); + } + + function loadRowData(containerID: number) { + const { request, controller } = AxiosGetHelper( + `/api/v1/containers/${containerID}/keys`, + cancelSignal.current + ); + cancelSignal.current = controller; + + request.then(response => { + const containerKeysResponse: ContainerKeysResponse = response.data; + expandedRowSetter({ + ...expandedRow, + [containerID]: { + ...expandedRow[containerID], + loading: false, + dataSource: containerKeysResponse.keys, + totalCount: containerKeysResponse.totalCount + } + }); + }).catch(error => { + expandedRowSetter({ + ...expandedRow, + [containerID]: { + ...expandedRow[containerID], + loading: false + } + }); + showDataFetchError((error as AxiosError).toString()); + }); + } + + function getFilteredData(data: Container[]) { + + return data?.filter( + (container: Container) => { + return (searchColumn === 'containerID') + ? container[searchColumn].toString().includes(searchTerm) + : container[searchColumn].includes(searchTerm) + } + ) ?? []; + } + + function onRowExpandClick(expanded: boolean, record: Container) { + if (expanded) { + loadRowData(record.containerID); + } + else { + cancelSignal.current && cancelSignal.current.abort(); + } + } + + function expandedRowRender(record: Container) { + const containerId = record.containerID + const containerKeys: ExpandedRowState = expandedRow[containerId]; + const dataSource = containerKeys?.dataSource ?? []; + const paginationConfig: TablePaginationConfig = { + showTotal: (total: number, range) => `${range[0]}-${range[1]} of ${total} Keys` + } + + return ( + `${record.Volume}/${record.Bucket}/${record.Key}`} + locale={{ filterTitle: '' }} /> + ) + }; + + const paginationConfig: TablePaginationConfig = { + showTotal: (total: number, range) => ( + `${range[0]}-${range[1]} of ${total} Containers` + ), + showSizeChanger: true + }; + + return ( +
+
+ + ); +} + +export default ContainerTable; \ No newline at end of file diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/buckets/buckets.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/buckets/buckets.tsx index 1e2de307b17b..1590f36a4aaa 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/buckets/buckets.tsx +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/buckets/buckets.tsx @@ -26,6 +26,7 @@ import AclPanel from '@/v2/components/aclDrawer/aclDrawer'; import Search from '@/v2/components/search/search'; import MultiSelect from '@/v2/components/select/multiSelect'; import SingleSelect, { Option } from '@/v2/components/select/singleSelect'; +import BucketsTable, { COLUMNS } from '@/v2/components/tables/bucketsTable'; import { AutoReloadHelper } from '@/utils/autoReloadHelper'; import { AxiosGetHelper, cancelRequests } from "@/utils/axiosRequestHelper"; @@ -39,7 +40,6 @@ import { } from '@/v2/types/bucket.types'; import './buckets.less'; -import BucketsTable, { COLUMNS } from '@/v2/components/tables/bucketsTable'; const LIMIT_OPTIONS: Option[] = [ diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/containers/containers.less b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/containers/containers.less new file mode 100644 index 000000000000..f6328ccee640 --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/containers/containers.less @@ -0,0 +1,50 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF 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. +*/ + +.content-div { + min-height: unset; + + .table-header-section { + display: flex; + justify-content: space-between; + align-items: center; + + .table-filter-section { + font-size: 14px; + font-weight: normal; + display: flex; + column-gap: 8px; + padding: 16px 8px; + align-items: center; + } + } +} + +.highlight-content { + color: #989898; + + .highlight-content-value { + color: #000000; + font-weight: 400; + font-size: 30px; + } +} + +.datanode-container-v2 { + padding: 6px 0px; +} \ No newline at end of file diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/containers/containers.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/containers/containers.tsx new file mode 100644 index 000000000000..78f6424c6e7a --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/containers/containers.tsx @@ -0,0 +1,283 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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, { useRef, useState } from "react"; +import moment from "moment"; +import { AxiosError } from "axios"; +import { Card, Row, Tabs } from "antd"; +import { ValueType } from "react-select/src/types"; + +import Search from "@/v2/components/search/search"; +import MultiSelect, { Option } from "@/v2/components/select/multiSelect"; +import ContainerTable, { COLUMNS } from "@/v2/components/tables/containersTable"; +import AutoReloadPanel from "@/components/autoReloadPanel/autoReloadPanel"; +import { showDataFetchError } from "@/utils/common"; +import { AutoReloadHelper } from "@/utils/autoReloadHelper"; +import { AxiosGetHelper, cancelRequests } from "@/utils/axiosRequestHelper"; +import { useDebounce } from "@/v2/hooks/debounce.hook"; + +import { + Container, + ContainerState, + ExpandedRow +} from "@/v2/types/container.types"; + +import './containers.less'; + + +const SearchableColumnOpts = [{ + label: 'Container ID', + value: 'containerID' +}, { + label: 'Pipeline ID', + value: 'pipelineID' +}] + +const defaultColumns = COLUMNS.map(column => ({ + label: column.title as string, + value: column.key as string +})); + +const Containers: React.FC<{}> = () => { + + const cancelSignal = useRef(); + + const [state, setState] = useState({ + lastUpdated: 0, + columnOptions: defaultColumns, + missingContainerData: [], + underReplicatedContainerData: [], + overReplicatedContainerData: [], + misReplicatedContainerData: [], + }); + const [expandedRow, setExpandedRow] = useState({}); + + const [loading, setLoading] = useState(false); + const [selectedColumns, setSelectedColumns] = useState(defaultColumns); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedTab, setSelectedTab] = useState('1'); + const [searchColumn, setSearchColumn] = useState<'containerID' | 'pipelineID'>('containerID'); + + const debouncedSearch = useDebounce(searchTerm, 300); + + function loadData() { + setLoading(true); + + const { request, controller } = AxiosGetHelper( + '/api/v1/containers/unhealthy', + cancelSignal.current + ); + + cancelSignal.current = controller; + + request.then(response => { + const containers: Container[] = response.data.containers; + + const missingContainerData: Container[] = containers?.filter( + container => container.containerState === 'MISSING' + ) ?? []; + const underReplicatedContainerData: Container[] = containers?.filter( + container => container.containerState === 'UNDER_REPLICATED' + ) ?? []; + const overReplicatedContainerData: Container[] = containers?.filter( + container => container.containerState === 'OVER_REPLICATED' + ) ?? []; + const misReplicatedContainerData: Container[] = containers?.filter( + container => container.containerState === 'MIS_REPLICATED' + ) ?? []; + + setState({ + ...state, + missingContainerData: missingContainerData, + underReplicatedContainerData: underReplicatedContainerData, + overReplicatedContainerData: overReplicatedContainerData, + misReplicatedContainerData: misReplicatedContainerData, + lastUpdated: Number(moment()) + }); + setLoading(false) + }).catch(error => { + setLoading(false); + showDataFetchError((error as AxiosError).toString()); + }); + } + + function handleColumnChange(selected: ValueType) { + setSelectedColumns(selected as Option[]); + } + + const autoReloadHelper: AutoReloadHelper = new AutoReloadHelper(loadData); + + React.useEffect(() => { + autoReloadHelper.startPolling(); + loadData(); + + return (() => { + autoReloadHelper.stopPolling(); + cancelRequests([cancelSignal.current!]) + }) + }, []); + + const { + lastUpdated, columnOptions, + missingContainerData, underReplicatedContainerData, + overReplicatedContainerData, misReplicatedContainerData + } = state; + + // Mapping the data to the Tab keys for enabling/disabling search + const dataToTabKeyMap: Record = { + 1: missingContainerData, + 2: underReplicatedContainerData, + 3: overReplicatedContainerData, + 4: misReplicatedContainerData + } + + const highlightData = ( +
+
+ Missing
+ {missingContainerData?.length ?? 'N/A'} +
+
+ Under-Replicated
+ {underReplicatedContainerData?.length ?? 'N/A'} +
+
+ Over-Replicated
+ {overReplicatedContainerData?.length ?? 'N/A'} +
+
+ Mis-Replicated
+ {misReplicatedContainerData?.length ?? 'N/A'} +
+
+ ) + + return ( + <> +
+ Containers + +
+
+
+ + + {highlightData} + + +
+
+
+
+ { }} + columnLength={columnOptions.length} /> +
+ ) => setSearchTerm(e.target.value) + } + onChange={(value) => { + setSearchTerm(''); + setSearchColumn(value as 'containerID' | 'pipelineID'); + }} /> +
+ setSelectedTab(activeKey)}> + + + + + + + + + + + + + +
+
+ + ); +} + +export default Containers; \ No newline at end of file diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/overview/overview.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/overview/overview.tsx index 0394c8ac511c..1568cb4b3ed3 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/overview/overview.tsx +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/overview/overview.tsx @@ -322,20 +322,13 @@ const Overview: React.FC<{}> = () => { ) - const containersLink = (missingContainersCount > 0) - ? ( - - ) : ( - - ) + const containersLink = ( + + ) return ( <> diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/routes-v2.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/routes-v2.tsx index 20907fd3ad5e..942ad16dcd1a 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/routes-v2.tsx +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/routes-v2.tsx @@ -23,6 +23,7 @@ const Buckets = lazy(() => import('@/v2/pages/buckets/buckets')); const Datanodes = lazy(() => import('@/v2/pages/datanodes/datanodes')); const Pipelines = lazy(() => import('@/v2/pages/pipelines/pipelines')); const DiskUsage = lazy(() => import('@/v2/pages/diskUsage/diskUsage')); +const Containers = lazy(() => import('@/v2/pages/containers/containers')); export const routesV2 = [ { @@ -48,5 +49,9 @@ export const routesV2 = [ { path: '/DiskUsage', component: DiskUsage + }, + { + path: '/Containers', + component: Containers } ]; diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/types/container.types.ts b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/types/container.types.ts new file mode 100644 index 000000000000..2467a0f26fda --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/types/container.types.ts @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 { Option } from "@/v2/components/select/multiSelect"; + +export type ContainerReplica = { + containerId: number; + datanodeUuid: string; + datanodeHost: string; + firstSeenTime: number; + lastSeenTime: number; + lastBcsId: number; +} + +export type Container = { + containerID: number; + containerState: string; + unhealthySince: number; + expectedReplicaCount: number; + actualReplicaCount: number; + replicaDeltaCount: number; + reason: string; + keys: number; + pipelineID: string; + replicas: ContainerReplica[]; +} + +type KeyResponseBlock = { + containerID: number; + localID: number; +} + +export type KeyResponse = { + Volume: string; + Bucket: string; + Key: string; + DataSize: number; + CompletePath: string; + Versions: number[]; + Blocks: Record; + CreationTime: string; + ModificationTime: string; +} + +export type ContainerKeysResponse = { + totalCount: number; + keys: KeyResponse[]; +} + +export type ContainerTableProps = { + loading: boolean; + data: Container[]; + searchColumn: 'containerID' | 'pipelineID'; + searchTerm: string; + selectedColumns: Option[]; + expandedRow: ExpandedRow; + expandedRowSetter: (arg0: ExpandedRow) => void; +} + + +export type ExpandedRow = { + [key: number]: ExpandedRowState; +} + +export type ExpandedRowState = { + loading: boolean; + containerId: number; + dataSource: KeyResponse[]; + totalCount: number; +} + +export type ContainerState = { + lastUpdated: number; + columnOptions: Option[]; + missingContainerData: Container[]; + underReplicatedContainerData: Container[]; + overReplicatedContainerData: Container[]; + misReplicatedContainerData: Container[]; +} \ No newline at end of file diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/utils/momentUtils.ts b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/utils/momentUtils.ts index fb553d0db3f1..daaae2d54d37 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/utils/momentUtils.ts +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/utils/momentUtils.ts @@ -61,3 +61,8 @@ export function getDurationFromTimestamp(timestamp: number): string { return (elapsedTime.length === 0) ? 'Just now' : elapsedTime.join(' '); } + +export function getFormattedTime(time: number | string, format: string) { + if (typeof time === 'string') return moment(time).format(format); + return (time > 0) ? moment(time).format(format) : 'N/A'; +}