diff --git a/ozone-ui/packages/om/mock/jmxData.cjs b/ozone-ui/packages/om/mock/jmxData.cjs index 2944840ffc3d..393d537bc9cb 100644 --- a/ozone-ui/packages/om/mock/jmxData.cjs +++ b/ozone-ui/packages/om/mock/jmxData.cjs @@ -159,6 +159,90 @@ const deletingServiceMetrics = { NumKeysPurged: 1275, }; +// OM metrics bean (RPC operation counters + object counts). Values mirror a real +// cluster's shape: Key has activity plus a couple of failures (one of them a +// failure-only op — NumKeyLists is 0 but NumKeyListFails is not); Get/Volume/Bucket/ +// List have data; the remaining metric types are absent, so their dropdown options +// are greyed out. +const omMetrics = { + name: 'Hadoop:service=OzoneManager,name=OMMetrics', + modelerType: 'OMMetrics', + 'tag.Context': 'ozone', + 'tag.Hostname': 'node1.test.site.com', + + // Object counts (summary cards). + NumVolumes: 1, + NumBuckets: 2, + NumKeys: 485, + TotalDataCommitted: 62259, + + // Key — active, with a failing op (Delete) and a failure-only op (List). + NumKeyOps: 2895, + NumKeyAllocate: 965, + NumKeyAllocateFails: 0, + NumKeyCommits: 965, + NumKeyCommitFails: 0, + NumKeyDeletes: 965, + NumKeyDeleteFails: 5, + NumKeyHSyncs: 0, + NumKeyLists: 0, + NumKeyListFails: 100, + NumKeyLookup: 0, + NumKeyLookupFails: 0, + NumKeyRenames: 0, + NumKeyRenameFails: 0, + + // Get. + NumGetServiceLists: 990, + + // Volume. + NumVolumeOps: 24, + NumVolumeCreates: 1, + NumVolumeCreateFails: 0, + NumVolumeInfos: 20, + NumVolumeInfoFails: 0, + NumVolumeLists: 3, + NumVolumeListFails: 0, + + // Bucket. + NumBucketOps: 60, + NumBucketCreates: 2, + NumBucketCreateFails: 0, + NumBucketInfos: 48, + NumBucketInfoFails: 0, + NumBucketLists: 10, + NumBucketListFails: 0, + + // List. + NumListStatus: 45, + NumListStatusFails: 0, + + // Snapshot — active, with a failing Create op. + NumSnapshotOps: 11, + NumSnapshotCreates: 8, + NumSnapshotCreateFails: 1, + NumSnapshotDeletes: 3, + NumSnapshotDeleteFails: 0, + + // ACL operations (Add / Set / Remove). + NumAddAcl: 40, + NumAddAclFails: 0, + NumSetAcl: 15, + NumSetAclFails: 0, + NumRemoveAcl: 5, + NumRemoveAclFails: 0, + + // Multipart upload (Initiate / Commit / Complete / Abort) — Commit is failing. + NumInitiateMultipartUploads: 20, + NumInitiateMultipartUploadFails: 0, + NumCommitMultipartUploadParts: 18, + NumCommitMultipartUploadPartFails: 2, + NumCompleteMultipartUploads: 18, + NumCompleteMultipartUploadFails: 0, + NumAbortMultipartUploads: 2, + NumAbortMultipartUploadFails: 0, +}; + /** * Ordered match table. The mock server picks the first entry whose `test` * matches the requested `qry` and returns `{ beans }`. @@ -169,5 +253,6 @@ module.exports = [ { test: /service=RaftServer/i, beans: [ratisRaftServer] }, { test: /electionCount/i, beans: [leaderElectionCount] }, { test: /lastLeaderElectionElapsedTime/i, beans: [leaderElectionElapsed] }, + { test: /name=OMMetrics/i, beans: [omMetrics] }, { test: /DeletingServiceMetrics/i, beans: [deletingServiceMetrics] }, ]; diff --git a/ozone-ui/packages/om/src/App.tsx b/ozone-ui/packages/om/src/App.tsx index e4f4d940d19a..bef2dd1131f6 100644 --- a/ozone-ui/packages/om/src/App.tsx +++ b/ozone-ui/packages/om/src/App.tsx @@ -31,6 +31,7 @@ import { import { navItems, SIDEBAR_WIDTH } from './navigation'; import { JMX_QUERY_KEY } from './api/useJmx'; import OverviewPage from './pages/Overview/OverviewPage'; +import MetricsPage from './pages/Metrics/MetricsPage'; import Placeholder from './pages/Placeholder'; /** 404 page for unknown routes; the action returns to the Overview. */ @@ -89,12 +90,19 @@ function AppShell() { } /> } /> - } /> - } /> - } /> + {/* Metrics group */} + } /> + } + /> + } /> + } /> + } /> + {/* Common tools group */} + } /> } /> - } /> - } /> + } /> } /> diff --git a/ozone-ui/packages/om/src/__tests__/metrics.parsers.test.ts b/ozone-ui/packages/om/src/__tests__/metrics.parsers.test.ts new file mode 100644 index 000000000000..1fa713e27df0 --- /dev/null +++ b/ozone-ui/packages/om/src/__tests__/metrics.parsers.test.ts @@ -0,0 +1,138 @@ +/** + * 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 { describe, expect, it } from 'vitest'; +import { parseOmMetrics, type OMMetricsBean } from '../api/metrics'; + +const bean: OMMetricsBean = { + name: 'Hadoop:service=OzoneManager,name=OMMetrics', + 'tag.Hostname': 'node1', + NumVolumes: 1, + NumBuckets: 2, + NumKeys: 485, + TotalDataCommitted: 62259, + NumKeyOps: 2895, + NumKeyAllocate: 965, + NumKeyAllocateFails: 0, + NumKeyCommits: 965, + NumKeyCommitFails: 0, + NumKeyDeletes: 965, + NumKeyDeleteFails: 5, + NumKeyHSyncs: 0, + NumKeyLists: 0, + NumKeyListFails: 100, + NumGetServiceLists: 990, +}; + +describe('parseOmMetrics — summary', () => { + it('extracts the object-count summary', () => { + const { summary } = parseOmMetrics(bean); + expect(summary).toEqual({ + volumes: 1, + buckets: 2, + keys: 485, + totalCommittedBytes: 62259, + }); + }); + + it('is null-safe', () => { + expect(parseOmMetrics(undefined).summary.keys).toBe(0); + }); +}); + +describe('parseOmMetrics — Key operations', () => { + const key = parseOmMetrics(bean).byType.Key; + + it('joins plural request names to their singular failure counterpart', () => { + const commit = key.operations.find((o) => o.name === 'Commit'); + const del = key.operations.find((o) => o.name === 'Delete'); + expect(commit).toMatchObject({ requests: 965, failures: 0, status: 'Active' }); + // Deletes has 5 DeleteFails → Warning. + expect(del).toMatchObject({ requests: 965, failures: 5, status: 'Warning' }); + }); + + it('keeps a request name that has no failure counterpart', () => { + const get = parseOmMetrics(bean).byType.Get; + expect(get.operations.map((o) => o.name)).toContain('ServiceLists'); + }); + + it('surfaces a failure-only operation (0 requests) with Warning status', () => { + const list = key.operations.find((o) => o.name === 'List'); + expect(list).toMatchObject({ requests: 0, failures: 100, status: 'Warning' }); + }); + + it('omits operations with no activity (0 requests, 0 failures)', () => { + // NumKeyHSyncs = 0 with no failures → not shown. + expect(key.operations.find((o) => o.name === 'HSync')).toBeUndefined(); + }); + + it('uses NumOps for totalRequests', () => { + expect(key.totalRequests).toBe(2895); + }); + + it('sorts operations by requests descending', () => { + const requests = key.operations.map((o) => o.requests); + expect(requests).toEqual([...requests].sort((a, b) => b - a)); + }); +}); + +describe('parseOmMetrics — enabled flag', () => { + const { byType } = parseOmMetrics(bean); + + it('enables types with activity', () => { + expect(byType.Key.enabled).toBe(true); + expect(byType.Get.enabled).toBe(true); + }); + + it('disables types with no metrics in the bean', () => { + expect(byType.Snapshot.enabled).toBe(false); + expect(byType.Snapshot.operations).toHaveLength(0); + }); +}); + +describe('parseOmMetrics — parsing safety', () => { + it('does not treat NumKeys/NumVolumes/NumBuckets counts as operations', () => { + const { byType } = parseOmMetrics({ NumKeys: 485, NumVolumes: 1, NumBuckets: 2 }); + expect(byType.Key.operations).toHaveLength(0); + expect(byType.Volume.operations).toHaveLength(0); + expect(byType.Bucket.operations).toHaveLength(0); + expect(byType.Key.enabled).toBe(false); + }); + + it('ignores non-numeric bean values (tags, modelerType, name)', () => { + const { byType } = parseOmMetrics({ + name: 'Hadoop:service=OzoneManager,name=OMMetrics', + modelerType: 'OMMetrics', + 'tag.Hostname': 'node1', + }); + expect(Object.values(byType).every((t) => t.operations.length === 0)).toBe(true); + }); + + it('shows a failure-only operation type and marks it enabled', () => { + const { byType } = parseOmMetrics({ NumRecoverLeaseFails: 3 }); + expect(byType.Recover.enabled).toBe(true); + expect(byType.Recover.operations).toEqual([ + expect.objectContaining({ name: 'Lease', requests: 0, failures: 3, status: 'Warning' }), + ]); + }); + + it('falls back to summing requests when NumOps is absent', () => { + const { byType } = parseOmMetrics({ NumGetServiceLists: 990, NumGetAcl: 10 }); + expect(byType.Get.totalRequests).toBe(1000); + }); +}); diff --git a/ozone-ui/packages/om/src/api/metrics.ts b/ozone-ui/packages/om/src/api/metrics.ts new file mode 100644 index 000000000000..b00fbc5a2749 --- /dev/null +++ b/ozone-ui/packages/om/src/api/metrics.ts @@ -0,0 +1,213 @@ +/** + * 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. + */ + +/** JMX query for the OM metrics bean (RPC operation counters + object counts). */ +export const OM_METRICS_QUERY = 'Hadoop:service=OzoneManager,name=OMMetrics'; + +/** + * Operation categories exposed by `OMMetrics` as `Num` counters. Order + * drives the metric-type dropdown; a type with no activity is disabled there. + */ +export const METRIC_TYPES = [ + 'Get', + 'Abort', + 'Add', + 'Block', + 'Bucket', + 'Cancel', + 'Commit', + 'Complete', + 'Create', + 'Delete', + 'Expired', + 'Initiate', + 'Key', + 'List', + 'Lookup', + 'Open', + 'Put', + 'Recover', + 'Remove', + 'Set', + 'Snapshot', + 'Tenant', + 'Trash', + 'Volume', +] as const; + +export type MetricType = (typeof METRIC_TYPES)[number]; + +/** Raw OM metrics JMX bean — dynamic `Num*`/count keys plus string tags. */ +export type OMMetricsBean = Record; + +export type OperationStatus = 'Active' | 'Warning' | 'Inactive'; + +export interface MetricOperation { + key: string; + /** Canonical operation label (e.g. `Allocate`, `Commit`, `Delete`). */ + name: string; + /** Successful request count for this operation. */ + requests: number; + /** Failure count for this operation. */ + failures: number; + status: OperationStatus; +} + +export interface MetricTypeData { + type: string; + /** Total requests: the bean's `NumOps` if present, else the sum of requests. */ + totalRequests: number; + /** Operations with any activity (requests or failures), busiest first. */ + operations: MetricOperation[]; + /** False when the type has no activity at all — its dropdown option is greyed out. */ + enabled: boolean; +} + +export interface MetricsSummary { + volumes: number; + buckets: number; + keys: number; + totalCommittedBytes: number; +} + +export interface ParsedOmMetrics { + summary: MetricsSummary; + byType: Record; +} + +/** `Num` with an optional `Fails` suffix; `NumOps` is the total. */ +const METRIC_KEY_RE = /^Num([A-Z][a-z]+)([A-Z].+?)(Fails)?$/; + +function numeric(bean: OMMetricsBean, key: string): number { + const value = bean[key]; + return typeof value === 'number' ? value : 0; +} + +/** Drop a trailing plural `s` so request names line up with failure names. */ +function singular(name: string): string { + return name.endsWith('s') ? name.slice(0, -1) : name; +} + +function statusFor(requests: number, failures: number): OperationStatus { + if (failures > 0) { + return 'Warning'; + } + if (requests > 0) { + return 'Active'; + } + return 'Inactive'; +} + +interface TypeAccumulator { + ops?: number; + requests: Map; + failures: Map; +} + +/** + * Parse the `OMMetrics` bean into per-type operation data and the object-count + * summary. Request counters (`NumKeyCommits`) are joined to their failure + * counterpart (`NumKeyCommitFails`) into a single operation row — matching by the + * failure name or the request name minus a trailing `s`, so `Commits`→`Commit` + * while an op with no failure counterpart (e.g. `ServiceLists`) keeps its name. + * Failures with no matching request become their own rows (requests = 0). + */ +export function parseOmMetrics(bean: OMMetricsBean | undefined): ParsedOmMetrics { + const data = bean ?? {}; + + const summary: MetricsSummary = { + volumes: numeric(data, 'NumVolumes'), + buckets: numeric(data, 'NumBuckets'), + keys: numeric(data, 'NumKeys'), + totalCommittedBytes: numeric(data, 'TotalDataCommitted'), + }; + + const acc: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (typeof value !== 'number') { + continue; + } + const match = key.match(METRIC_KEY_RE); + if (!match) { + continue; + } + const [, type, name, failed] = match; + const bucket = (acc[type] ??= { requests: new Map(), failures: new Map() }); + if (failed) { + bucket.failures.set(name, (bucket.failures.get(name) ?? 0) + value); + } else if (name === 'Ops') { + bucket.ops = value; + } else { + bucket.requests.set(name, (bucket.requests.get(name) ?? 0) + value); + } + } + + const byType: Record = {}; + for (const type of METRIC_TYPES) { + const bucket = acc[type]; + const requests = bucket?.requests ?? new Map(); + const failures = new Map(bucket?.failures ?? new Map()); + const operations: MetricOperation[] = []; + + for (const [reqName, reqVal] of requests) { + let label = reqName; + let failVal = 0; + const sg = singular(reqName); + if (failures.has(reqName)) { + failVal = failures.get(reqName) ?? 0; + failures.delete(reqName); + } else if (failures.has(sg)) { + label = sg; + failVal = failures.get(sg) ?? 0; + failures.delete(sg); + } + operations.push({ + key: label, + name: label, + requests: reqVal, + failures: failVal, + status: statusFor(reqVal, failVal), + }); + } + + // Failures with no matching request — shown in the table, excluded from the bar. + for (const [failName, failVal] of failures) { + operations.push({ + key: failName, + name: failName, + requests: 0, + failures: failVal, + status: statusFor(0, failVal), + }); + } + + const shown = operations + .filter((op) => op.requests > 0 || op.failures > 0) + .sort((a, b) => b.requests - a.requests); + const sumRequests = [...requests.values()].reduce((sum, n) => sum + n, 0); + + byType[type] = { + type, + totalRequests: bucket?.ops ?? sumRequests, + operations: shown, + enabled: shown.length > 0, + }; + } + + return { summary, byType }; +} diff --git a/ozone-ui/packages/om/src/navigation.tsx b/ozone-ui/packages/om/src/navigation.tsx index c81668f55ec5..769da7b633e9 100644 --- a/ozone-ui/packages/om/src/navigation.tsx +++ b/ozone-ui/packages/om/src/navigation.tsx @@ -21,10 +21,12 @@ import { ApiOutlined, BarChartOutlined, BlockOutlined, - BookOutlined, + CameraOutlined, ClusterOutlined, ControlOutlined, DashboardOutlined, + DeleteOutlined, + FileTextOutlined, HistoryOutlined, } from '@ant-design/icons'; @@ -42,7 +44,9 @@ const navItem = (key: string, label: string, path: string, icon: MenuItem['icon' /** * Ozone Manager navigation rail. Mirrors the "Sidebar Navigation" in the design: - * primary items, then a "Diagnostics" group and a "Links" group. + * Overview and Configuration at the top, then a "Metrics" group of per-subsystem + * metrics views and a "Common tools" group. The OM Metrics page is the + * "Ozone Manager" item under the Metrics group. */ export const navItems: MenuItem[] = [ navItem('overview', 'Overview', '/', ), @@ -54,32 +58,34 @@ export const navItems: MenuItem[] = [ ), { type: 'group', - key: 'group-diagnostics', - label: 'Diagnostics', + key: 'group-metrics', + label: 'Metrics', children: [ - navItem('rpc', 'Remote Procedure Call', '/rpc', ), + navItem('rpc', 'Remote Procedure Call', '/metrics/rpc', ), navItem( - 'ozone-manager', + 'ratis-event-timeline', + 'Ratis Event Timeline', + '/metrics/ratis-event-timeline', + + ), + navItem( + 'om-metrics', 'Ozone Manager', - '/ozone-manager', + '/metrics/ozone-manager', ), - navItem('jmx', 'JMX', '/jmx-info', ), - navItem('stacks', 'Stacks', '/stacks', ), + navItem('deletion', 'Deletion', '/metrics/deletion', ), + navItem('snapshots', 'Snapshots', '/metrics/snapshots', ), ], }, { type: 'group', - key: 'group-links', - label: 'Links', + key: 'group-common-tools', + label: 'Common tools', children: [ - navItem( - 'documentation', - 'Documentation', - '/documentation', - - ), - navItem('log-levels', 'Log levels', '/log-levels', ), + navItem('jmx', 'JMX', '/jmx', ), + navItem('stacks', 'Stacks', '/stacks', ), + navItem('log-levels', 'Log Levels', '/log-levels', ), ], }, ]; diff --git a/ozone-ui/packages/om/src/pages/Metrics/MetricsPage.tsx b/ozone-ui/packages/om/src/pages/Metrics/MetricsPage.tsx new file mode 100644 index 000000000000..c04da7443484 --- /dev/null +++ b/ozone-ui/packages/om/src/pages/Metrics/MetricsPage.tsx @@ -0,0 +1,181 @@ +/** + * 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, { Suspense, useMemo, useState } from 'react'; +import { Empty, Select, Skeleton, type TableColumnsType } from 'antd'; +import filesize from 'filesize'; +import { + Card, + Chip, + chartPalette, + DataTable, + KeyValuePair, + PageHeader, + QueryErrorBoundary, + StackedBar, + spacing, + type ChipColor, +} from '@ozone-ui/shared'; +import { + METRIC_TYPES, + OM_METRICS_QUERY, + parseOmMetrics, + type MetricOperation, + type OMMetricsBean, + type OperationStatus, +} from '../../api/metrics'; +import { useSuspenseJmxBean } from '../../api/useJmx'; + +const summaryGridStyle: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', + gap: `${spacing.lg}px ${spacing.xl}px`, +}; + +const statusColor: Record = { + Active: 'green', + Warning: 'orange', + Inactive: 'neutral', +}; + +const columns: TableColumnsType = [ + { + title: 'Status', + dataIndex: 'status', + key: 'status', + width: 170, + render: (status: OperationStatus) => ( + + {status} + + ), + }, + { title: 'Operational Action', dataIndex: 'name', key: 'name' }, + { + title: 'Failures', + dataIndex: 'failures', + key: 'failures', + width: 170, + render: (failures: number) => failures.toLocaleString('en-US'), + }, +]; + +const MetricsContent: React.FC = () => { + const { data: bean, isEmpty } = useSuspenseJmxBean(OM_METRICS_QUERY); + const { summary, byType } = useMemo(() => parseOmMetrics(bean), [bean]); + + const firstEnabled = METRIC_TYPES.find((t) => byType[t]?.enabled); + const [selectedType, setSelectedType] = useState(firstEnabled ?? 'Key'); + const selected = byType[selectedType] ?? byType.Key; + + const barSegments = useMemo( + () => + (selected?.operations ?? []) + .filter((op) => op.requests > 0) + .map((op, i) => ({ + label: op.name, + value: op.requests, + color: chartPalette[i % chartPalette.length], + })), + [selected] + ); + + // Active types first (preserving METRIC_TYPES order), then the greyed-out + // inactive ones — so the dropdown surfaces the types that have data. + const options = [ + ...METRIC_TYPES.filter((type) => byType[type]?.enabled), + ...METRIC_TYPES.filter((type) => !byType[type]?.enabled), + ].map((type) => ({ + value: type, + label: type, + disabled: !byType[type]?.enabled, + })); + + // No OMMetrics bean returned (e.g. servlet returned `{ beans: [] }`). + if (isEmpty || !bean) { + return ; + } + + return ( +
+ {/* Object-count summary */} + +
+ + + + +
+
+ + {/* Active operations for the selected metric type */} + +
+
+