From c2054c1f2f12b6e462dd332c4a10d523f50bb37c Mon Sep 17 00:00:00 2001 From: Abhishek Pal Date: Wed, 29 Jul 2026 23:23:22 +0530 Subject: [PATCH 1/5] HDDS-15985. Add new Overview page for Ozone Manager --- ozone-ui/README.md | 52 +++- ozone-ui/package.json | 2 + ozone-ui/packages/om/mock/jmxData.cjs | 169 +++++++++++ ozone-ui/packages/om/mock/server.cjs | 46 +++ ozone-ui/packages/om/package.json | 2 + ozone-ui/packages/om/src/App.tsx | 68 +++-- ozone-ui/packages/om/src/api/jmx.ts | 73 +++++ ozone-ui/packages/om/src/api/overview.ts | 287 ++++++++++++++++++ ozone-ui/packages/om/src/api/useJmx.ts | 53 ++++ ozone-ui/packages/om/src/index.css | 77 +---- ozone-ui/packages/om/src/main.tsx | 5 +- ozone-ui/packages/om/src/navigation.tsx | 73 +++++ .../om/src/pages/Overview/OverviewPage.tsx | 59 ++++ .../om/src/pages/Overview/SectionBody.tsx | 50 +++ .../sections/InstanceDetailsSection.tsx | 76 +++++ .../pages/Overview/sections/JvmSection.tsx | 240 +++++++++++++++ .../sections/MetadataVolumeSection.tsx | 55 ++++ .../pages/Overview/sections/RolesSection.tsx | 138 +++++++++ .../packages/om/src/pages/Placeholder.tsx | 35 +++ ozone-ui/packages/om/vite.config.ts | 5 + .../src/components/AppLayout/AppLayout.tsx | 17 +- .../src/components/DataTable/DataTable.tsx | 193 ++++++++++++ .../components/DataTable/TablePagination.tsx | 149 +++++++++ .../shared/src/components/Icon/Icon.tsx | 94 +++++- .../components/KeyValuePair/KeyValuePair.tsx | 19 +- .../components/SearchInput/SearchInput.tsx | 49 +++ .../shared/src/components/Section/Section.tsx | 83 +++++ .../shared/src/components/Sidebar/Sidebar.tsx | 165 +++++++++- .../src/components/UtilityBar/UtilityBar.tsx | 70 ++++- ozone-ui/packages/shared/src/index.ts | 8 + .../packages/shared/src/theme/antdTheme.ts | 41 ++- ozone-ui/packages/shared/src/theme/tokens.ts | 31 +- .../packages/shared/src/utils/menuUtils.ts | 12 +- 33 files changed, 2350 insertions(+), 146 deletions(-) create mode 100644 ozone-ui/packages/om/mock/jmxData.cjs create mode 100644 ozone-ui/packages/om/mock/server.cjs create mode 100644 ozone-ui/packages/om/src/api/jmx.ts create mode 100644 ozone-ui/packages/om/src/api/overview.ts create mode 100644 ozone-ui/packages/om/src/api/useJmx.ts create mode 100644 ozone-ui/packages/om/src/navigation.tsx create mode 100644 ozone-ui/packages/om/src/pages/Overview/OverviewPage.tsx create mode 100644 ozone-ui/packages/om/src/pages/Overview/SectionBody.tsx create mode 100644 ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx create mode 100644 ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx create mode 100644 ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx create mode 100644 ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx create mode 100644 ozone-ui/packages/om/src/pages/Placeholder.tsx create mode 100644 ozone-ui/packages/shared/src/components/DataTable/DataTable.tsx create mode 100644 ozone-ui/packages/shared/src/components/DataTable/TablePagination.tsx create mode 100644 ozone-ui/packages/shared/src/components/SearchInput/SearchInput.tsx create mode 100644 ozone-ui/packages/shared/src/components/Section/Section.tsx diff --git a/ozone-ui/README.md b/ozone-ui/README.md index 33f2e9e91692..6f53158c7050 100644 --- a/ozone-ui/README.md +++ b/ozone-ui/README.md @@ -51,6 +51,12 @@ ozone-ui/ # pnpm workspace root ├── recon/ # @ozone-ui/ozone-recon (Vite app) ├── scm/ # @ozone-ui/ozone-scm (Vite app) └── om/ # @ozone-ui/ozone-om (Vite app) + ├── mock/ # json-server JMX mock (server.cjs, jmxData.cjs) + └── src/ + ├── api/ # JMX client + section-driven data hooks/parsers + ├── pages/ # Overview page + section components + ├── navigation.tsx # sidebar nav items + └── App.tsx # utility bar + sidebar + routes ``` ## Prerequisites @@ -77,9 +83,39 @@ pnpm build:shared # compile @ozone-ui/shared -> packages/shared/dist pnpm dev:recon # start the Recon app dev server pnpm dev:scm # start the SCM app dev server -pnpm dev:om # start the OM app dev server +pnpm dev:om # start the OM app dev server (http://localhost:3000) ``` +> Tip: run `pnpm build:shared --watch` (or rebuild it after edits) whenever you +> change `@ozone-ui/shared`, since apps import the compiled `dist/` output. + +## Mock backends (local development) + +The apps talk to their Ozone service over HTTP. To develop without a live +cluster, each app can be paired with a **json-server** mock of its backend. +Mock commands are namespaced per service (`mock:om`, and later `mock:scm`, +`mock:recon`, …) so every sub-service can host its own mock independently. + +### OM (Ozone Manager) + +The OM app reads runtime state from the OM JMX servlet (`GET /jmx?qry=`). +The mock in `packages/om/mock/` replays captured JMX responses on port `9878`; +the OM dev server proxies `/jmx` to it (see `packages/om/vite.config.ts`). + +```bash +cd ozone-ui +pnpm build:shared # once, and after any shared change + +pnpm dev:om:mock # OM mock (:9878) + OM dev server (:3000) together +# — or run them separately — +pnpm mock:om # just the OM JMX mock on :9878 +pnpm dev:om # just the OM dev server on :3000 +``` + +Then open http://localhost:3000. To point the app at a real OM instead of the +mock, change the `/jmx` proxy target in `packages/om/vite.config.ts` (or serve +the built app from the OM itself, where `/jmx` is same-origin). + ## Build ```bash @@ -167,14 +203,22 @@ export default function App() { - **`components/`** (derived from the components recurring across the mockups) - `UtilityBar` — global top bar (leading/title, centre, actions). - `Sidebar` — collapsible, router-aware navigation rail driven by `items` - (with `path`s) and `logo` props; integrates with `react-router-dom`. - - `AppLayout` — page shell (sider + header + content). + (with `path`s, plus `group`/`divider` entries) and `logo` props; integrates + with `react-router-dom`. + - `AppLayout` — page shell with an optional full-width `utilityBar` slot above + the sider + content row. - `PageHeader` — page title with breadcrumb, subtitle and actions. + - `Section` — labelled content block: title, optional supporting text and + actions, followed by its content. - `Card` — surface with `outlined`/`elevated`/`filled` emphasis and an optional `collapsible` header. - - `KeyValuePair` — label/value pair (vertical or horizontal, optional link/copy). + - `KeyValuePair` — label/value pair (vertical or horizontal, optional + link/copy and an info `tooltip`). + - `DataTable` — themed Ant Design table with an optional title + filter/actions + toolbar and a `TablePagination` footer (client-side paging via `paginated`). - `Chip` — pill: `full`/`dot` variant, `standard`/`small` size, colour and `selected`/`closable` states. + - `SearchInput` — text field with a leading search glyph (table toolbars). - `Alert` — inline status banner (info/success/warning/error). - `TextLink` — themed inline link with optional external affordance. - `IconButton` — square icon-only button with accessible label + tooltip. diff --git a/ozone-ui/package.json b/ozone-ui/package.json index 673eccd9db74..7f095df71e28 100644 --- a/ozone-ui/package.json +++ b/ozone-ui/package.json @@ -13,6 +13,8 @@ "dev:recon": "pnpm --filter @ozone-ui/ozone-recon run dev", "dev:scm": "pnpm --filter @ozone-ui/ozone-scm run dev", "dev:om": "pnpm --filter @ozone-ui/ozone-om run dev", + "mock:om": "pnpm --filter @ozone-ui/ozone-om run mock:om", + "dev:om:mock": "pnpm --filter @ozone-ui/ozone-om run dev:om:mock", "clean": "rm -rf build && pnpm -r exec rm -rf dist node_modules", "clean:cache": "rm -rf node_modules/.vite && pnpm -r exec rm -rf node_modules/.vite", "clean:all": "pnpm run clean && pnpm run clean:cache", diff --git a/ozone-ui/packages/om/mock/jmxData.cjs b/ozone-ui/packages/om/mock/jmxData.cjs new file mode 100644 index 000000000000..af76ac068d47 --- /dev/null +++ b/ozone-ui/packages/om/mock/jmxData.cjs @@ -0,0 +1,169 @@ +/* + * 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. + */ + +/** + * Captured Ozone Manager JMX responses, used by the json-server mock (server.cjs) + * to serve the OM UI without a live cluster. Bean payloads mirror real + * `GET /jmx?qry=...` responses; a few very large class-path strings are trimmed + * (they are not surfaced in the UI). + */ + +const ozoneManagerInfo = { + name: 'Hadoop:service=OzoneManager,name=OzoneManagerInfo,component=ServerRuntime', + modelerType: 'org.apache.hadoop.ozone.om.OzoneManager', + RpcPort: '9862', + RatisRoles: + ' { HostName: node1.test.site.com | Node-Id: om1546336043 | Ratis-Port : 9872 | Role: FOLLOWER} { HostName: node2.test.site.com | Node-Id: om1546336047 | Ratis-Port : 9872 | Role: LEADER} { HostName: node3.test.site.com | Node-Id: om1546336039 | Ratis-Port : 9872 | Role: FOLLOWER} ', + RatisLogDirectory: '/var/lib/hadoop-ozone/om/ratis', + RocksDbDirectory: '/var/lib/hadoop-ozone/om/data', + Version: '2.3.0, r0a1b2c3d4e5f60718293a4b5c6d7e8f901234567', + SoftwareVersion: '2.3.0', + StartedTimeInMillis: 1785178223133, + CompileInfo: 'built from source (branch master, commit 0a1b2c3)', +}; + +const runtime = { + name: 'java.lang:type=Runtime', + modelerType: 'sun.management.RuntimeImpl', + BootClassPathSupported: true, + VmName: 'OpenJDK 64-Bit Server VM', + VmVendor: 'AdoptOpenJDK', + VmVersion: '25.232-b09', + LibraryPath: + ':/opt/ozone/current/lib/hadoop-ozone/share/ozone/lib', + Uptime: 78876304, + ManagementSpecVersion: '1.2', + SpecName: 'Java Virtual Machine Specification', + SpecVendor: 'Oracle Corporation', + SpecVersion: '1.8', + Name: '2455265@node1.test.site.com', + ClassPath: '/etc/hadoop-ozone/conf:<...trimmed...>', + StartTime: 1785178198793, + SystemProperties: [ + { key: 'java.runtime.name', value: 'OpenJDK Runtime Environment' }, + { key: 'java.runtime.version', value: '1.8.0_232-b09' }, + { key: 'java.version', value: '1.8.0_232' }, + { key: 'java.vm.name', value: 'OpenJDK 64-Bit Server VM' }, + { key: 'java.vm.vendor', value: 'AdoptOpenJDK' }, + { key: 'java.vm.version', value: '25.232-b09' }, + { key: 'java.home', value: '/usr/lib/jvm/java-1.8.0/jre' }, + { key: 'java.io.tmpdir', value: '/tmp' }, + { key: 'user.name', value: 'hdfs' }, + { key: 'user.timezone', value: 'UTC' }, + { key: 'os.name', value: 'Linux' }, + { key: 'os.arch', value: 'amd64' }, + { key: 'os.version', value: '5.4.243-1.el7.elrepo.x86_64' }, + { key: 'file.encoding', value: 'UTF-8' }, + { key: 'hadoop.home.dir', value: '/opt/ozone/current/lib/hadoop-ozone' }, + { key: 'hadoop.id.str', value: 'hdds-hdfs' }, + { key: 'hadoop.log.dir', value: '/var/log/hadoop-ozone' }, + { key: 'hadoop.log.file', value: 'ozone.log' }, + { key: 'hadoop.root.logger', value: 'INFO,console' }, + { key: 'hadoop.security.logger', value: 'INFO,NullAppender' }, + { key: 'hadoop.policy.file', value: 'hadoop-policy.xml' }, + { key: 'proc_om', value: '' }, + { key: 'sun.java.command', value: 'org.apache.hadoop.ozone.om.OzoneManagerStarter' }, + { + key: 'java.library.path', + value: ':/opt/ozone/current/lib/hadoop-ozone/share/ozone/lib', + }, + { + key: 'org.apache.ratis.thirdparty.io.netty.allocator.useCacheForAllThreads', + value: 'false', + }, + { key: 'sun.security.krb5.disableReferrals', value: 'true' }, + { key: 'jdk.tls.ephemeralDHKeySize', value: '2048' }, + ], + InputArguments: [ + '-Dproc_om', + '-Dorg.apache.ratis.thirdparty.io.netty.allocator.useCacheForAllThreads=false', + '-Xmx2511M', + '-Xloggc:/var/log/hadoop-ozone/gc-OM-2026-07-27_18-49-49.log', + '-verbose:gc', + '-XX:+PrintGCDetails', + '-XX:+PrintGCTimeStamps', + '-XX:+PrintGCDateStamps', + '-XX:+UseConcMarkSweepGC', + '-XX:CMSInitiatingOccupancyFraction=70', + '-XX:+CMSParallelRemarkEnabled', + '-Dsun.security.krb5.disableReferrals=true', + '-Djdk.tls.ephemeralDHKeySize=2048', + '-Dcom.sun.management.jmxremote.ssl.enabled.protocols=TLSv1.2', + '-XX:OnOutOfMemoryError=/opt/ozone/bin/oom-handler.sh', + '-Dlog4j.configurationFile=/etc/hadoop-ozone/conf/om-audit-log4j2.properties', + '-Djava.library.path=:/opt/ozone/current/lib/hadoop-ozone/share/ozone/lib', + '-Dhadoop.log.dir=/var/log/hadoop-ozone', + '-Dhadoop.log.file=ozone.log', + '-Dhadoop.home.dir=/opt/ozone/current/lib/hadoop-ozone', + '-Dhadoop.id.str=hdds-hdfs', + '-Dhadoop.root.logger=INFO,console', + '-Dhadoop.policy.file=hadoop-policy.xml', + '-Dhadoop.security.logger=INFO,NullAppender', + ], + ObjectName: 'java.lang:type=Runtime', +}; + +const ratisRaftServer = { + name: 'Ratis:service=RaftServer,group=group-0A1B2C3D4E5F,id=om1546336043', + modelerType: 'org.apache.ratis.server.impl.RaftServerImpl$RaftServerJmxAdapter', + Id: 'om1546336043', + LeaderId: 'om1546336047', + Role: ' FOLLOWER', + Groups: ['group-0A1B2C3D4E5F'], + Followers: [], + CurrentTerm: 5, + GroupId: 'group-0A1B2C3D4E5F', +}; + +const leaderElectionCount = { + name: 'ratis:name=ratis.leader_election.om1546336043@group-0A1B2C3D4E5F.electionCount', + modelerType: 'com.codahale.metrics.JmxReporter$JmxCounter', + Count: 1, +}; + +const leaderElectionElapsed = { + name: 'ratis:name=ratis.leader_election.om1546336043@group-0A1B2C3D4E5F.lastLeaderElectionElapsedTime', + modelerType: 'com.codahale.metrics.JmxReporter$JmxGauge', + Value: 78848822, +}; + +const deletingServiceMetrics = { + name: 'Hadoop:service=OzoneManager,name=DeletingServiceMetrics', + modelerType: 'DeletingServiceMetrics', + 'tag.Context': 'ozone', + 'tag.Hostname': 'node1.test.site.com', + MetricsResetTimeStamp: 1785178221, + KeysReclaimedInInterval: 0, + ReclaimedSizeInInterval: 0, + LastAOSPurgeTermId: 5, + LastAOSPurgeTransactionId: 23245, + NumKeysPurged: 1275, +}; + +/** + * Ordered match table. The mock server picks the first entry whose `test` + * matches the requested `qry` and returns `{ beans }`. + */ +module.exports = [ + { test: /component=ServerRuntime/i, beans: [ozoneManagerInfo] }, + { test: /java\.lang:type=Runtime/i, beans: [runtime] }, + { test: /service=RaftServer/i, beans: [ratisRaftServer] }, + { test: /electionCount/i, beans: [leaderElectionCount] }, + { test: /lastLeaderElectionElapsedTime/i, beans: [leaderElectionElapsed] }, + { test: /DeletingServiceMetrics/i, beans: [deletingServiceMetrics] }, +]; diff --git a/ozone-ui/packages/om/mock/server.cjs b/ozone-ui/packages/om/mock/server.cjs new file mode 100644 index 000000000000..246acd2b2c65 --- /dev/null +++ b/ozone-ui/packages/om/mock/server.cjs @@ -0,0 +1,46 @@ +/* + * 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. + */ + +/** + * json-server based mock for the Ozone Manager JMX endpoint. + * + * Serves `GET /jmx?qry=` from the captured responses in + * jmxData.cjs so the OM UI can run without a live cluster. Start with + * `pnpm mock:om` (or `pnpm dev:om:mock` to run it alongside the OM dev + * server, which proxies /jmx to it). + */ + +const jsonServer = require('json-server'); +const jmxData = require('./jmxData.cjs'); + +const PORT = process.env.MOCK_PORT ? Number(process.env.MOCK_PORT) : 9878; + +const server = jsonServer.create(); +server.use(jsonServer.defaults()); + +server.get('/jmx', (req, res) => { + const qry = String(req.query.qry || ''); + const match = jmxData.find((entry) => entry.test.test(qry)); + // Mirror the real JMX servlet, which always returns a { beans: [...] } shape. + res.json({ beans: match ? match.beans : [] }); +}); + +server.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`OM JMX mock listening on http://localhost:${PORT}/jmx?qry=...`); +}); diff --git a/ozone-ui/packages/om/package.json b/ozone-ui/packages/om/package.json index 06f5991be535..ec4397d6f9cd 100644 --- a/ozone-ui/packages/om/package.json +++ b/ozone-ui/packages/om/package.json @@ -4,6 +4,8 @@ "version": "1.0.0", "scripts": { "dev": "vite --port=3000", + "mock:om": "node mock/server.cjs", + "dev:om:mock": "npm-run-all -p mock:om dev", "build": "vite build", "lint": "eslint ." }, diff --git a/ozone-ui/packages/om/src/App.tsx b/ozone-ui/packages/om/src/App.tsx index 4f8a0c8c4384..ea66ad252725 100644 --- a/ozone-ui/packages/om/src/App.tsx +++ b/ozone-ui/packages/om/src/App.tsx @@ -15,26 +15,60 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useState } from 'react'; -import { Button } from 'antd'; -import './App.css'; -function App() { - const [count, setCount] = useState(0); +import { Routes, Route, Navigate } from 'react-router-dom'; +import { AppstoreOutlined } from '@ant-design/icons'; +import { AppLayout, Chip, IconButton, Sidebar, UtilityBar } from '@ozone-ui/shared'; +import { navItems, SIDEBAR_WIDTH } from './navigation'; +import OverviewPage from './pages/Overview/OverviewPage'; +import Placeholder from './pages/Placeholder'; + +/** Product branding: the app name plus a chip showing the current host. */ +const BrandTitle = () => { + const host = window.location.hostname; + return ( + + Ozone Manager + {host && ( + + {host} + + )} + + ); +}; + +const utilityBar = ( + } + label="App switcher" + tooltip={null} + /> + } + branding={} + /> +); +function App() { return ( -
-

Ozone OM

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

Click on the Vite and React logos to learn more

-
+ } + > + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); } diff --git a/ozone-ui/packages/om/src/api/jmx.ts b/ozone-ui/packages/om/src/api/jmx.ts new file mode 100644 index 000000000000..c4af126c6b6b --- /dev/null +++ b/ozone-ui/packages/om/src/api/jmx.ts @@ -0,0 +1,73 @@ +/** + * 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 axios from 'axios'; + +/** + * The OM exposes runtime state via its JMX servlet at `GET /jmx?qry=`, + * always returning `{ beans: [...] }`. In development Vite proxies `/jmx` to the + * json-server mock (see `mock/server.cjs`). + * + * Fetches are keyed and de-duplicated by query string (see {@link fetchJmxBeans}): + * several sections of a page may depend on the same MBean (e.g. the OM + * ServerRuntime bean feeds Instance Details, Roles and Metadata Volume), yet the + * query is only issued once. Sections also fetch lazily, so a query is never + * sent for a section that is not rendered — this keeps us from pulling the full + * multi-thousand-line JMX dump when only a few beans are needed. + */ +const client = axios.create({ baseURL: '' }); + +export interface JmxResponse { + beans: T[]; +} + +/** Issue a JMX query and return the matching MBeans (no caching). */ +export async function queryJmx(qry: string): Promise { + const { data } = await client.get>('/jmx', { params: { qry } }); + return data?.beans ?? []; +} + +/** In-flight / resolved query cache, keyed by the JMX query string. */ +const cache = new Map>(); + +/** + * Fetch MBeans for a query, sharing a single request across all callers that ask + * for the same query. Failed requests are evicted so they can be retried. + */ +export function fetchJmxBeans(qry: string): Promise { + let pending = cache.get(qry) as Promise | undefined; + if (!pending) { + pending = queryJmx(qry).catch((err) => { + cache.delete(qry); + throw err; + }); + cache.set(qry, pending as Promise); + } + return pending; +} + +/** Fetch a single MBean for a query (the first bean), or `undefined`. */ +export async function fetchJmxBean(qry: string): Promise { + const beans = await fetchJmxBeans(qry); + return beans[0]; +} + +/** Drop all cached queries so the next fetch re-hits the endpoint (refresh). */ +export function clearJmxCache(): void { + cache.clear(); +} diff --git a/ozone-ui/packages/om/src/api/overview.ts b/ozone-ui/packages/om/src/api/overview.ts new file mode 100644 index 000000000000..6c25fbc9ef0e --- /dev/null +++ b/ozone-ui/packages/om/src/api/overview.ts @@ -0,0 +1,287 @@ +/** + * 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 moment from 'moment'; + +/** + * JMX MBean queries used by the Overview sections. Kept in one place so each + * section references a query by name; sections that share a query (e.g. the OM + * ServerRuntime bean) are de-duplicated to a single request by the JMX cache. + */ +export const JMX_QUERY = { + /** OM ServerRuntime bean: RPC port, ratis roles, data dirs, version, build. */ + omInfo: 'Hadoop:service=*,name=*,component=ServerRuntime', + /** This node's Ratis RaftServer bean: id, leader, role, group. */ + ratisServer: 'Ratis:service=RaftServer,group=*,id=*', + /** JVM runtime bean: input arguments and system properties. */ + runtime: 'java.lang:type=Runtime', + /** + * Ratis leader-election metrics for the current node. The name patterns are + * matched by the mock; a live cluster may need the node id/group interpolated + * (e.g. `ratis:name=ratis.leader_election.@.electionCount`). + */ + leaderElectionCount: 'ratis:name=ratis.leader_election.*electionCount', + leaderElectionElapsed: 'ratis:name=ratis.leader_election.*lastLeaderElectionElapsedTime', +} as const; + +/* --------------------------------- Beans ---------------------------------- */ + +export interface OzoneManagerInfoBean { + RpcPort: string; + RatisRoles: string; + RatisLogDirectory: string; + RocksDbDirectory: string; + Version: string; + SoftwareVersion: string; + StartedTimeInMillis: number; + CompileInfo: string; +} + +export interface RatisServerBean { + Id: string; + LeaderId: string; + Role: string; + GroupId: string; + CurrentTerm: number; +} + +/** Ratis leader-election count metric (current node). */ +export interface LeaderElectionCountBean { + Count: number; +} + +/** Ratis last-leader-election elapsed-time metric in milliseconds (current node). */ +export interface LeaderElectionElapsedBean { + Value: number; +} + +export interface SystemProperty { + key: string; + value: string; +} + +export interface RuntimeBean { + VmName: string; + VmVendor: string; + VmVersion: string; + Name: string; + InputArguments: string[]; + SystemProperties: SystemProperty[]; +} + +/* ------------------------------ View models ------------------------------- */ + +export interface KeyValue { + key: string; + label: string; + value: string; + copyable?: boolean; + tooltip?: string; +} + +export type RatisRoleName = 'LEADER' | 'FOLLOWER' | string; + +export interface RatisRole { + key: string; + hostName: string; + nodeId: string; + ratisPort: string; + role: RatisRoleName; + /** Derived follower sync state; `null` for the leader row. */ + readiness: 'Synced' | 'Lagging' | null; + /** True for the node serving this JMX endpoint. */ + isCurrent: boolean; +} + +export type JvmParameterCategory = 'System & Framework' | 'Memory & GC' | 'System Property'; + +export interface JvmParameter { + key: string; + parameter: string; + value: string; + category: JvmParameterCategory; +} + +/* -------------------------------- Parsers --------------------------------- */ + +/** + * Parse the OM `RatisRoles` string, e.g. + * `{ HostName: h1 | Node-Id: om1 | Ratis-Port : 9872 | Role: FOLLOWER } {...}`. + */ +export function parseRatisRoles(raw: string, currentNodeId?: string): RatisRole[] { + const groups = raw?.match(/\{[^}]*\}/g) ?? []; + return groups.map((group, index) => { + const fields: Record = {}; + group + .replace(/[{}]/g, '') + .split('|') + .forEach((part) => { + const sep = part.indexOf(':'); + if (sep === -1) return; + fields[part.slice(0, sep).trim()] = part.slice(sep + 1).trim(); + }); + const role = (fields.Role ?? '').toUpperCase(); + const nodeId = fields['Node-Id'] ?? ''; + return { + key: nodeId || String(index), + hostName: fields.HostName ?? '', + nodeId, + ratisPort: fields['Ratis-Port'] ?? '', + role, + // The leader has no "readiness"; followers are shown as synced with the leader. + readiness: role === 'LEADER' ? null : 'Synced', + isCurrent: !!currentNodeId && nodeId === currentNodeId, + }; + }); +} + +const MEMORY_GC = /Xm[xsn]|Xss|gc|CMS|Heap|Memory/i; + +function categorize(parameter: string): JvmParameterCategory { + return MEMORY_GC.test(parameter) ? 'Memory & GC' : 'System & Framework'; +} + +/** Split a single JVM argument into a `{ parameter, value }` pair. */ +function splitArgument(arg: string): { parameter: string; value: string } { + if (arg.startsWith('-D')) { + const eq = arg.indexOf('='); + return eq === -1 + ? { parameter: arg, value: 'Present' } + : { parameter: arg.slice(0, eq), value: arg.slice(eq + 1) }; + } + if (arg.startsWith('-XX:')) { + const body = arg.slice(4); + if (body.startsWith('+')) return { parameter: arg, value: 'Enabled' }; + if (body.startsWith('-')) return { parameter: arg, value: 'Disabled' }; + const eq = body.indexOf('='); + return eq === -1 + ? { parameter: arg, value: 'Present' } + : { parameter: `-XX:${body.slice(0, eq)}`, value: body.slice(eq + 1) }; + } + if (arg.startsWith('-Xloggc:')) { + return { parameter: '-Xloggc', value: arg.slice('-Xloggc:'.length) }; + } + if (/^-Xm[xsn]/.test(arg) || arg.startsWith('-Xss')) { + return { parameter: arg.slice(0, 4), value: arg.slice(4) }; + } + if (arg.startsWith('-verbose:')) { + return { parameter: '-verbose', value: arg.slice('-verbose:'.length) }; + } + return { parameter: arg, value: 'Present' }; +} + +/** Parse JVM `InputArguments` into categorised parameter rows. */ +export function parseJvmArguments(args: string[]): JvmParameter[] { + return (args ?? []).map((arg, index) => { + const { parameter, value } = splitArgument(arg); + return { key: `arg-${index}`, parameter, value, category: categorize(parameter) }; + }); +} + +/** Map JVM `SystemProperties` into parameter rows (for the "Show JVM Modules" toggle). */ +export function toSystemPropertyRows(props: SystemProperty[]): JvmParameter[] { + return (props ?? []).map((prop, index) => ({ + key: `prop-${index}`, + parameter: prop.key, + value: prop.value === '' ? '—' : prop.value, + category: 'System Property', + })); +} + +function formatHeap(xmx: string | undefined): string { + if (!xmx) return 'Not set'; + const match = xmx.slice(4).match(/^(\d+)\s*([kKmMgG])?/); + if (!match) return xmx.slice(4); + const size = Number(match[1]); + const unit = (match[2] ?? 'B').toUpperCase(); + const megabytes = unit === 'G' ? size * 1024 : unit === 'K' ? Math.round(size / 1024) : size; + return `${megabytes.toLocaleString('en-US')} MB`; +} + +function detectGarbageCollector(args: string[]): string { + const flags = args.join(' '); + if (/UseG1GC/.test(flags)) return 'G1GC'; + if (/UseConcMarkSweepGC/.test(flags)) return 'ConcMarkSweep (CMS)'; + if (/UseParallelGC/.test(flags)) return 'Parallel'; + if (/UseZGC/.test(flags)) return 'ZGC'; + if (/UseShenandoahGC/.test(flags)) return 'Shenandoah'; + return 'Default'; +} + +export interface JvmHighlight { + key: string; + label: string; + value: string; + tooltip: string; +} + +/** Build the JVM "Highlights" key-value pairs from the runtime bean. */ +export function buildJvmHighlights(runtime: RuntimeBean): JvmHighlight[] { + const props = new Map(runtime.SystemProperties.map((p) => [p.key, p.value])); + const args = runtime.InputArguments ?? []; + const runtimeName = props.get('java.runtime.name') ?? runtime.VmName; + const javaVersion = props.get('java.version') ?? runtime.VmVersion; + const gcPause = args.find((a) => a.startsWith('-XX:MaxGCPauseMillis=')); + return [ + { + key: 'runtime', + label: 'Runtime Environment', + value: `${runtimeName} ${javaVersion}`.trim(), + tooltip: `${runtime.VmName} (${runtime.VmVendor})`, + }, + { + key: 'heap', + label: 'Max Heap Memory', + value: formatHeap(args.find((a) => a.startsWith('-Xmx'))), + tooltip: 'Configured JVM maximum heap size (-Xmx).', + }, + { + key: 'gc', + label: 'Garbage Collector', + value: detectGarbageCollector(args), + tooltip: 'Active garbage collector, detected from JVM flags.', + }, + { + key: 'gcPause', + label: 'GC Pause Target', + value: gcPause ? `${gcPause.split('=')[1]} ms` : 'Not set', + tooltip: 'Target max GC pause (-XX:MaxGCPauseMillis), when configured.', + }, + ]; +} + +/** Format an epoch-millis timestamp the way the Overview cards display it. */ +export function formatStarted(millis: number): string { + return moment(millis).format('MMM D, YYYY h:mm:ss A'); +} + +/** + * Format an elapsed duration (milliseconds) as e.g. "2 days 3 hours", + * "12 hours 40 mins", or "5 mins". Returns "—" for missing/negative input. + */ +export function formatElapsed(millis: number | undefined): string { + if (!millis || millis < 0) return '—'; + const totalMinutes = Math.floor(millis / 60000); + const days = Math.floor(totalMinutes / 1440); + const hours = Math.floor((totalMinutes % 1440) / 60); + const mins = totalMinutes % 60; + const unit = (n: number, name: string) => `${n} ${name}${n === 1 ? '' : 's'}`; + if (days > 0) return `${unit(days, 'day')} ${unit(hours, 'hour')}`; + if (hours > 0) return `${unit(hours, 'hour')} ${unit(mins, 'min')}`; + return unit(mins, 'min'); +} diff --git a/ozone-ui/packages/om/src/api/useJmx.ts b/ozone-ui/packages/om/src/api/useJmx.ts new file mode 100644 index 000000000000..02ab2d0b77bd --- /dev/null +++ b/ozone-ui/packages/om/src/api/useJmx.ts @@ -0,0 +1,53 @@ +/** + * 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 { useEffect, useState } from 'react'; +import { fetchJmxBean } from './jmx'; + +export interface JmxBeanState { + data?: T; + loading: boolean; + error?: Error; +} + +/** + * Fetch a single JMX MBean for a section. Requests are de-duplicated by query + * (see {@link fetchJmxBean}), so multiple sections depending on the same MBean + * share one network call. Pass a changing `refreshToken` (together with + * `clearJmxCache()`) to force a refetch. + */ +export function useJmxBean(qry: string, refreshToken = 0): JmxBeanState { + const [state, setState] = useState>({ loading: true }); + + useEffect(() => { + let active = true; + setState({ loading: true }); + fetchJmxBean(qry) + .then((data) => { + if (active) setState({ data, loading: false }); + }) + .catch((error: Error) => { + if (active) setState({ loading: false, error }); + }); + return () => { + active = false; + }; + }, [qry, refreshToken]); + + return state; +} diff --git a/ozone-ui/packages/om/src/index.css b/ozone-ui/packages/om/src/index.css index 492fe513fe13..688d7480b4c6 100644 --- a/ozone-ui/packages/om/src/index.css +++ b/ozone-ui/packages/om/src/index.css @@ -15,78 +15,15 @@ * limitations under the License. */ -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; +html, +body, +#root { + height: 100%; } body { margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; + font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} \ No newline at end of file diff --git a/ozone-ui/packages/om/src/main.tsx b/ozone-ui/packages/om/src/main.tsx index e4b310bf1344..fc232ad0a9f4 100644 --- a/ozone-ui/packages/om/src/main.tsx +++ b/ozone-ui/packages/om/src/main.tsx @@ -17,6 +17,7 @@ */ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; import { ThemeProvider } from '@ozone-ui/shared'; import '@fontsource/roboto/400.css'; import '@fontsource/roboto/500.css'; @@ -27,7 +28,9 @@ import './index.css'; createRoot(document.getElementById('root')!).render( - + + + ); diff --git a/ozone-ui/packages/om/src/navigation.tsx b/ozone-ui/packages/om/src/navigation.tsx new file mode 100644 index 000000000000..2e486226230b --- /dev/null +++ b/ozone-ui/packages/om/src/navigation.tsx @@ -0,0 +1,73 @@ +/** + * 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 { type MenuItem } from '@ozone-ui/shared'; +import { + ApiOutlined, + BarChartOutlined, + BlockOutlined, + BookOutlined, + ClusterOutlined, + ControlOutlined, + DashboardOutlined, + HistoryOutlined, +} from '@ant-design/icons'; + +/** Common footprint for the navigation glyphs. */ +const ICON_SIZE = 18; +const iconStyle = { fontSize: ICON_SIZE }; + +/** A leaf navigation item paired with the icon it renders in the rail. */ +const navItem = (key: string, label: string, path: string, icon: MenuItem['icon']): MenuItem => ({ + key, + label, + path, + icon, +}); + +/** + * Ozone Manager navigation rail. Mirrors the "Sidebar Navigation" in the design: + * primary items, then a "Diagnostics" group and a "Links" group. + */ +export const navItems: MenuItem[] = [ + navItem('overview', 'Overview', '/', ), + navItem('configuration', 'Configuration', '/configuration', ), + { + type: 'group', + key: 'group-diagnostics', + label: 'Diagnostics', + children: [ + navItem('rpc', 'Remote Procedure Call', '/rpc', ), + navItem('ozone-manager', 'Ozone Manager', '/ozone-manager', ), + navItem('jmx', 'JMX', '/jmx-info', ), + navItem('stacks', 'Stacks', '/stacks', ), + ], + }, + { + type: 'group', + key: 'group-links', + label: 'Links', + children: [ + navItem('documentation', 'Documentation', '/documentation', ), + navItem('log-levels', 'Log levels', '/log-levels', ), + ], + }, +]; + +/** Product branding shown in the top utility bar. */ +export const SIDEBAR_WIDTH = 248; diff --git a/ozone-ui/packages/om/src/pages/Overview/OverviewPage.tsx b/ozone-ui/packages/om/src/pages/Overview/OverviewPage.tsx new file mode 100644 index 000000000000..3a823cfdd982 --- /dev/null +++ b/ozone-ui/packages/om/src/pages/Overview/OverviewPage.tsx @@ -0,0 +1,59 @@ +/** + * 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, { useState } from 'react'; +import { Button } from 'antd'; +import { PageHeader, Icon } from '@ozone-ui/shared'; +import { clearJmxCache } from '../../api/jmx'; +import InstanceDetailsSection from './sections/InstanceDetailsSection'; +import RolesSection from './sections/RolesSection'; +import MetadataVolumeSection from './sections/MetadataVolumeSection'; +import JvmSection from './sections/JvmSection'; + +/** + * OM Overview page. Each section fetches its own JMX MBean lazily; sections that + * share a query (the OM ServerRuntime bean feeds three of them) are de-duplicated + * to a single request by the JMX cache. Refresh clears the cache and re-fetches. + */ +export const OverviewPage: React.FC = () => { + const [refreshToken, setRefreshToken] = useState(0); + + const refresh = () => { + clearJmxCache(); + setRefreshToken((t) => t + 1); + }; + + return ( +
+ } onClick={refresh}> + Refresh + + } + /> + + + + +
+ ); +}; + +export default OverviewPage; diff --git a/ozone-ui/packages/om/src/pages/Overview/SectionBody.tsx b/ozone-ui/packages/om/src/pages/Overview/SectionBody.tsx new file mode 100644 index 000000000000..eb89589e51bd --- /dev/null +++ b/ozone-ui/packages/om/src/pages/Overview/SectionBody.tsx @@ -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. + */ + +import React from 'react'; +import { Skeleton } from 'antd'; +import { Alert } from '@ozone-ui/shared'; + +export interface SectionBodyProps { + loading: boolean; + error?: Error; + /** Number of skeleton rows to show while loading. Defaults to 2. */ + skeletonRows?: number; + children: React.ReactNode; +} + +/** + * Renders a section's async state: a skeleton while loading, an error alert on + * failure, or the resolved content. + */ +export const SectionBody: React.FC = ({ + loading, + error, + skeletonRows = 2, + children, +}) => { + if (error) { + return ; + } + if (loading) { + return ; + } + return <>{children}; +}; + +export default SectionBody; diff --git a/ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx b/ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx new file mode 100644 index 000000000000..80e133451ab2 --- /dev/null +++ b/ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx @@ -0,0 +1,76 @@ +/** + * 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 from 'react'; +import { Card, KeyValuePair, Section } from '@ozone-ui/shared'; +import { + JMX_QUERY, + formatStarted, + parseRatisRoles, + type OzoneManagerInfoBean, + type RatisServerBean, +} from '../../../api/overview'; +import { useJmxBean } from '../../../api/useJmx'; +import SectionBody from '../SectionBody'; + +const kvGridStyle: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', + gap: '16px 24px', +}; + +export interface SectionProps { + refreshToken?: number; +} + +/** + * "Instance Details" card. Sourced from the OM ServerRuntime bean (shared with + * the Roles and Metadata Volume sections) plus this node's Ratis bean. + */ +export const InstanceDetailsSection: React.FC = ({ refreshToken }) => { + const { data: omInfo, loading, error } = useJmxBean( + JMX_QUERY.omInfo, + refreshToken + ); + const { data: ratis } = useJmxBean(JMX_QUERY.ratisServer, refreshToken); + + const currentHost = omInfo + ? parseRatisRoles(omInfo.RatisRoles, ratis?.Id).find((r) => r.isCurrent)?.hostName + : undefined; + + return ( +
+ + + {omInfo && ( +
+ + + + + + +
+ )} +
+
+
+ ); +}; + +export default InstanceDetailsSection; diff --git a/ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx b/ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx new file mode 100644 index 000000000000..84940ae92b83 --- /dev/null +++ b/ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx @@ -0,0 +1,240 @@ +/** + * 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, { useMemo, useState } from 'react'; +import { Button, Dropdown, message, type MenuProps, type TableColumnsType } from 'antd'; +import { DownOutlined } from '@ant-design/icons'; +import { Card, Chip, DataTable, Icon, KeyValuePair, Section, SearchInput } from '@ozone-ui/shared'; +import { + JMX_QUERY, + buildJvmHighlights, + parseJvmArguments, + toSystemPropertyRows, + type JvmParameter, + type JvmParameterCategory, + type RuntimeBean, +} from '../../../api/overview'; +import { useJmxBean } from '../../../api/useJmx'; +import SectionBody from '../SectionBody'; +import type { SectionProps } from './InstanceDetailsSection'; + +const highlightsGridStyle: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', + gap: '16px 24px', +}; + +const categoryColor: Record = { + 'System & Framework': 'blue', + 'Memory & GC': 'orange', + 'System Property': 'neutral', +}; + +const monospace: React.CSSProperties = { + fontFamily: "'Roboto Mono', monospace", + fontSize: 12, +}; + +const escapeXml = (s: string) => + s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + +/** Render parameter rows as a Hadoop-style XML configuration snippet. */ +const buildConfigXml = (params: JvmParameter[]): string => { + const body = params + .map( + (p) => + ` \n ${escapeXml(p.parameter)}\n ${escapeXml( + p.value + )}\n ` + ) + .join('\n'); + return `\n${body}\n`; +}; + +const columns: TableColumnsType = [ + { + title: 'Parameter', + dataIndex: 'parameter', + key: 'parameter', + width: '34%', + ellipsis: true, + render: (parameter: string) => {parameter}, + }, + { + title: 'Value', + dataIndex: 'value', + key: 'value', + width: '40%', + ellipsis: true, + render: (value: string) => {value}, + }, + { + title: 'Category', + dataIndex: 'category', + key: 'category', + width: '26%', + render: (category: JvmParameterCategory) => ( + + {category} + + ), + }, +]; + +/** + * "Java Virtual Machine" section: a Highlights card plus the searchable, + * filterable and paginated Parameters table. Sourced from the JVM runtime bean, + * fetched lazily only when this section renders. + */ +export const JvmSection: React.FC = ({ refreshToken }) => { + const { data: runtime, loading, error } = useJmxBean( + JMX_QUERY.runtime, + refreshToken + ); + + const [search, setSearch] = useState(''); + const [category, setCategory] = useState<'All' | JvmParameterCategory>('All'); + const [showModules, setShowModules] = useState(false); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + const highlights = useMemo(() => (runtime ? buildJvmHighlights(runtime) : []), [runtime]); + + const allRows = useMemo(() => { + if (!runtime) return []; + const args = parseJvmArguments(runtime.InputArguments); + return showModules ? [...args, ...toSystemPropertyRows(runtime.SystemProperties)] : args; + }, [runtime, showModules]); + + const rows = useMemo(() => { + const needle = search.trim().toLowerCase(); + return allRows.filter((row) => { + if (category !== 'All' && row.category !== category) return false; + if (!needle) return true; + return ( + row.parameter.toLowerCase().includes(needle) || row.value.toLowerCase().includes(needle) + ); + }); + }, [allRows, category, search]); + + const categoryOptions = [ + { label: 'All', value: 'All' }, + { label: 'System & Framework', value: 'System & Framework' }, + { label: 'Memory & GC', value: 'Memory & GC' }, + ...(showModules ? [{ label: 'System Property', value: 'System Property' }] : []), + ]; + + const categoryMenu: MenuProps = { + items: categoryOptions.map((o) => ({ key: o.value, label: o.label })), + selectable: true, + selectedKeys: [category], + onClick: ({ key }) => setCategory(key as 'All' | JvmParameterCategory), + }; + + // Copy the selected rows (or all filtered rows when none are selected) as a + // Hadoop-style XML configuration snippet. Selection resolves against the full + // row set so it survives search/category filtering. + const copyArguments = async () => { + const chosen = selectedRowKeys.length + ? allRows.filter((r) => selectedRowKeys.includes(r.key)) + : rows; + if (!chosen.length) return; + await navigator.clipboard.writeText(buildConfigXml(chosen)); + message.success( + `Copied ${chosen.length} ${chosen.length === 1 ? 'parameter' : 'parameters'} as XML` + ); + }; + + return ( +
+ + {runtime && ( +
+ +
+ {highlights.map((h) => ( + + ))} +
+
+ + + title="Parameters" + columns={columns} + dataSource={rows} + rowKey="key" + size="middle" + paginated + defaultPageSize={10} + rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }} + onRow={(record) => ({ + onClick: () => + setSelectedRowKeys((keys) => + keys.includes(record.key) + ? keys.filter((k) => k !== record.key) + : [...keys, record.key] + ), + style: { cursor: 'pointer' }, + })} + filters={ + <> + setSearch(e.target.value)} + placeholder="Search..." + width={256} + /> + + + + {category === 'All' ? 'All' : category} + + + + + setShowModules((v) => !v)} + style={{ cursor: 'pointer' }} + > + Show JVM Modules + + + } + actions={ + + } + /> +
+ )} +
+
+ ); +}; + +export default JvmSection; diff --git a/ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx b/ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx new file mode 100644 index 000000000000..1682e997d9c4 --- /dev/null +++ b/ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx @@ -0,0 +1,55 @@ +/** + * 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 from 'react'; +import { Card, KeyValuePair, Section } from '@ozone-ui/shared'; +import { JMX_QUERY, type OzoneManagerInfoBean } from '../../../api/overview'; +import { useJmxBean } from '../../../api/useJmx'; +import SectionBody from '../SectionBody'; +import type { SectionProps } from './InstanceDetailsSection'; + +const gridStyle: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', + gap: '16px 24px', +}; + +/** "Metadata Volume Information" card. Sourced from the OM ServerRuntime bean. */ +export const MetadataVolumeSection: React.FC = ({ refreshToken }) => { + const { data: omInfo, loading, error } = useJmxBean( + JMX_QUERY.omInfo, + refreshToken + ); + + return ( +
+ + + {omInfo && ( +
+ + +
+ )} +
+
+
+ ); +}; + +export default MetadataVolumeSection; diff --git a/ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx b/ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx new file mode 100644 index 000000000000..b50c5b524496 --- /dev/null +++ b/ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx @@ -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 React from 'react'; +import type { TableColumnsType } from 'antd'; +import { Chip, DataTable, KeyValuePair, Section, TextLink } from '@ozone-ui/shared'; +import { + JMX_QUERY, + formatElapsed, + parseRatisRoles, + type LeaderElectionCountBean, + type LeaderElectionElapsedBean, + type OzoneManagerInfoBean, + type RatisRole, + type RatisServerBean, +} from '../../../api/overview'; +import { useJmxBean } from '../../../api/useJmx'; +import SectionBody from '../SectionBody'; +import type { SectionProps } from './InstanceDetailsSection'; + +/** Grid for the per-host details revealed when a role row is expanded. */ +const detailsGridStyle: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', + gap: '16px 24px', + padding: '4px 8px 8px', +}; + +const columns: TableColumnsType = [ + { + title: 'Host Name', + dataIndex: 'hostName', + key: 'hostName', + render: (hostName: string, row) => ( + + {hostName} + + ), + }, + { title: 'Node ID', dataIndex: 'nodeId', key: 'nodeId' }, + { title: 'Ratis Port', dataIndex: 'ratisPort', key: 'ratisPort' }, + { + title: 'Role', + dataIndex: 'role', + key: 'role', + render: (role: string) => ( + + {role.charAt(0) + role.slice(1).toLowerCase()} + + ), + }, + { + title: 'Leader Readiness', + dataIndex: 'readiness', + key: 'readiness', + render: (readiness: RatisRole['readiness']) => + readiness ? ( + + {readiness} + + ) : ( + '—' + ), + }, +]; + +/** "Ozone Manager Roles" HA table. Sourced from the OM ServerRuntime bean. */ +export const RolesSection: React.FC = ({ refreshToken }) => { + const { data: omInfo, loading, error } = useJmxBean( + JMX_QUERY.omInfo, + refreshToken + ); + const { data: ratis } = useJmxBean(JMX_QUERY.ratisServer, refreshToken); + const { data: electionCount } = useJmxBean( + JMX_QUERY.leaderElectionCount, + refreshToken + ); + const { data: electionElapsed } = useJmxBean( + JMX_QUERY.leaderElectionElapsed, + refreshToken + ); + + const roles = omInfo ? parseRatisRoles(omInfo.RatisRoles, ratis?.Id) : []; + + // These details (RPC port, group id, leader-election metrics) are exposed only + // by the OM node serving the UI — so only the current node's row is + // expandable. Election count / elapsed time are hidden when absent or -1, + // mirroring the legacy OM UI. + const count = electionCount?.Count; + const elapsed = electionElapsed?.Value; + const showCount = count != null && count !== -1; + const showElapsed = elapsed != null && elapsed !== -1; + + const renderHostDetails = () => ( +
+ + + {showCount && } + {showElapsed && ( + + )} +
+ ); + + return ( +
+ + + columns={columns} + dataSource={roles} + rowKey="key" + size="middle" + expandable={{ + expandedRowRender: renderHostDetails, + rowExpandable: (record) => record.isCurrent, + }} + /> + +
+ ); +}; + +export default RolesSection; diff --git a/ozone-ui/packages/om/src/pages/Placeholder.tsx b/ozone-ui/packages/om/src/pages/Placeholder.tsx new file mode 100644 index 000000000000..842201b799b3 --- /dev/null +++ b/ozone-ui/packages/om/src/pages/Placeholder.tsx @@ -0,0 +1,35 @@ +/** + * 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 from 'react'; +import { Empty } from 'antd'; +import { PageHeader } from '@ozone-ui/shared'; + +export interface PlaceholderProps { + title: string; +} + +/** Stub page for navigation items that are not implemented yet. */ +export const Placeholder: React.FC = ({ title }) => ( +
+ + +
+); + +export default Placeholder; diff --git a/ozone-ui/packages/om/vite.config.ts b/ozone-ui/packages/om/vite.config.ts index 43accb318006..63135304badf 100644 --- a/ozone-ui/packages/om/vite.config.ts +++ b/ozone-ui/packages/om/vite.config.ts @@ -74,6 +74,11 @@ export default defineConfig({ '/api': { target: 'http://localhost:9862', }, + // JMX endpoint — proxied to the json-server mock in dev (see mock/server.cjs). + '/jmx': { + target: 'http://localhost:9878', + changeOrigin: true, + }, }, }, resolve: { diff --git a/ozone-ui/packages/shared/src/components/AppLayout/AppLayout.tsx b/ozone-ui/packages/shared/src/components/AppLayout/AppLayout.tsx index 8933c48e3ba1..12272016f883 100644 --- a/ozone-ui/packages/shared/src/components/AppLayout/AppLayout.tsx +++ b/ozone-ui/packages/shared/src/components/AppLayout/AppLayout.tsx @@ -23,6 +23,8 @@ import { semanticColors, spacing, textStyles } from '../../theme/tokens'; const { Header, Content } = Layout; export interface AppLayoutProps { + /** Full-width chrome rendered above the rail + content row (e.g. the shared `UtilityBar`). */ + utilityBar?: React.ReactNode; /** Navigation rail, typically the shared `Sidebar`. */ sider?: React.ReactNode; /** Page/section title rendered in the header. */ @@ -41,6 +43,7 @@ export interface AppLayoutProps { * layout background. Compose with the shared `Sidebar` for the `sider` slot. */ export const AppLayout: React.FC = ({ + utilityBar, sider, title, headerExtra, @@ -48,9 +51,14 @@ export const AppLayout: React.FC = ({ maxContentWidth, }) => { return ( - - {sider} - + // Lock the shell to the viewport so the rail (and its bottom collapse + // trigger) stay fixed while only the content column scrolls. + + {utilityBar} + + {sider} + {/* Breathing room between the navigation rail and the content column. */} + {(title || headerExtra) && (
= ({ > {children} - + + ); diff --git a/ozone-ui/packages/shared/src/components/DataTable/DataTable.tsx b/ozone-ui/packages/shared/src/components/DataTable/DataTable.tsx new file mode 100644 index 000000000000..4062527df671 --- /dev/null +++ b/ozone-ui/packages/shared/src/components/DataTable/DataTable.tsx @@ -0,0 +1,193 @@ +/** + * 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, { useEffect, useMemo, useState } from 'react'; +import { Table, Typography, type TableProps } from 'antd'; +import { radius, semanticColors, spacing, textStyles } from '../../theme/tokens'; +import Icon from '../Icon/Icon'; +import TablePagination from './TablePagination'; + +export interface DataTableProps extends Omit, 'pagination' | 'title'> { + /** Table title shown in the header bar. */ + title?: React.ReactNode; + /** Filter controls rendered on the left of the toolbar row (search, chips, ...). */ + filters?: React.ReactNode; + /** Action controls rendered on the right of the toolbar row (buttons, ...). */ + actions?: React.ReactNode; + /** Show the custom pagination footer and paginate `dataSource` client-side. */ + paginated?: boolean; + /** Initial rows per page when `paginated`. Defaults to 10. */ + defaultPageSize?: number; + /** Selectable page sizes when `paginated`. */ + pageSizeOptions?: number[]; +} + +/** + * Themed data table. Wraps Ant Design's `Table` with an optional header bar + * (title + a filter/actions toolbar) and the design-system `TablePagination` + * footer. When `paginated` is set the table paginates `dataSource` client-side; + * otherwise all rows are shown. All standard `Table` props are supported. + */ +export function DataTable({ + title, + filters, + actions, + paginated = false, + defaultPageSize = 10, + pageSizeOptions, + dataSource, + style, + expandable, + ...rest +}: DataTableProps) { + const [current, setCurrent] = useState(1); + const [pageSize, setPageSize] = useState(defaultPageSize); + + // Design-system row expander: a chevron that flips right→down (matching the + // "Icon-Only Expander" in the mocks). Applied by default when the caller + // enables `expandable`; callers may still override `expandIcon`. + const themedExpandIcon = ({ + expanded, + expandable: rowExpandable, + record, + onExpand, + }: { + expanded: boolean; + expandable: boolean; + record: T; + onExpand: (record: T, e: React.MouseEvent) => void; + }) => + rowExpandable ? ( + onExpand(record, e)} + style={{ display: 'inline-flex', cursor: 'pointer', color: semanticColors.textSecondary }} + > + + + ) : ( + + ); + + const mergedExpandable = expandable + ? { expandIcon: themedExpandIcon, ...expandable } + : undefined; + + const rows = dataSource ?? []; + const total = rows.length; + + // Return to the first page whenever the row set changes (e.g. search/filter), + // so the visible page never falls out of range. + useEffect(() => { + setCurrent(1); + }, [dataSource]); + + const pageRows = useMemo(() => { + if (!paginated) { + return rows; + } + const start = (current - 1) * pageSize; + return rows.slice(start, start + pageSize); + }, [paginated, rows, current, pageSize]); + + const hasHeader = title != null || filters != null || actions != null; + + return ( +
+ {hasHeader && ( +
+ {title != null && ( + + {title} + + )} + {(filters || actions) && ( +
+
+ {filters} +
+ {actions && ( +
+ {actions} +
+ )} +
+ )} +
+ )} + + + dataSource={pageRows} + pagination={false} + expandable={mergedExpandable} + {...rest} + /> + + {paginated && ( +
+ { + setPageSize(size); + setCurrent(1); + }} + /> +
+ )} +
+ ); +} + +export default DataTable; diff --git a/ozone-ui/packages/shared/src/components/DataTable/TablePagination.tsx b/ozone-ui/packages/shared/src/components/DataTable/TablePagination.tsx new file mode 100644 index 000000000000..1a9c3d14f61e --- /dev/null +++ b/ozone-ui/packages/shared/src/components/DataTable/TablePagination.tsx @@ -0,0 +1,149 @@ +/** + * 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 from 'react'; +import { Select, Typography } from 'antd'; +import { + DoubleLeftOutlined, + DoubleRightOutlined, + LeftOutlined, + RightOutlined, +} from '@ant-design/icons'; +import { semanticColors, spacing, textStyles } from '../../theme/tokens'; +import IconButton from '../IconButton/IconButton'; + +export interface TablePaginationProps { + /** Current page (1-based). */ + current: number; + /** Rows per page. */ + pageSize: number; + /** Total number of rows across all pages. */ + total: number; + /** Selectable page sizes. Defaults to `[10, 20, 50, 100]`. */ + pageSizeOptions?: number[]; + /** Called when the page changes. */ + onChange: (page: number) => void; + /** Called when the page size changes. */ + onPageSizeChange?: (pageSize: number) => void; + style?: React.CSSProperties; +} + +const labelStyle: React.CSSProperties = { + color: semanticColors.textSecondary, + fontSize: textStyles.bodySmall.fontSize, + lineHeight: `${textStyles.bodySmall.lineHeight}px`, +}; + +/** + * Data-table pagination footer. Renders a rows-per-page selector, a + * "Showing X-Y of Z" range summary and first/previous/next/last navigation with + * a page selector, matching the "Data Table Pagination" component in the mocks. + */ +export const TablePagination: React.FC = ({ + current, + pageSize, + total, + pageSizeOptions = [10, 20, 50, 100], + onChange, + onPageSizeChange, + style, +}) => { + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const from = total === 0 ? 0 : (current - 1) * pageSize + 1; + const to = Math.min(current * pageSize, total); + + const goTo = (page: number) => { + const next = Math.min(Math.max(1, page), pageCount); + if (next !== current) { + onChange(next); + } + }; + + return ( +
+
+ Rows per page: + ({ + label: String(i + 1), + value: i + 1, + }))} + style={{ width: 64 }} + /> + {`of ${pageCount} pages`} + } + label="Next page" + disabled={current >= pageCount} + onClick={() => goTo(current + 1)} + /> + } + label="Last page" + disabled={current >= pageCount} + onClick={() => goTo(pageCount)} + /> +
+
+ ); +}; + +export default TablePagination; diff --git a/ozone-ui/packages/shared/src/components/Icon/Icon.tsx b/ozone-ui/packages/shared/src/components/Icon/Icon.tsx index 4a4de97c39cf..59cedb89c0ff 100644 --- a/ozone-ui/packages/shared/src/components/Icon/Icon.tsx +++ b/ozone-ui/packages/shared/src/components/Icon/Icon.tsx @@ -40,7 +40,15 @@ export type IconName = | 'chevron-down' | 'chevron-up' | 'external-link' - | 'copy'; + | 'copy' + | 'grid' + | 'help' + | 'info' + | 'rpc' + | 'server' + | 'gauge' + | 'stack' + | 'logs'; /** SVG path data for each icon, drawn on a 24x24 viewBox with `currentColor`. */ const paths: Record = { @@ -199,6 +207,79 @@ const paths: Record = { strokeLinejoin="round" /> ), + grid: ( + + ), + help: ( + + ), + info: ( + + ), + rpc: ( + + ), + server: ( + + ), + gauge: ( + + ), + stack: ( + + ), + logs: ( + + ), }; export interface IconProps extends Omit, 'name'> { @@ -220,7 +301,16 @@ export const Icon: React.FC = ({ name, size = 16, style, ...rest }) = fill="currentColor" aria-hidden={rest['aria-label'] ? undefined : true} role={rest['aria-label'] ? 'img' : undefined} - style={{ display: 'inline-block', verticalAlign: 'middle', flexShrink: 0, ...style }} + style={{ + display: 'inline-block', + verticalAlign: 'middle', + flexShrink: 0, + // Decorative glyph: let the interactive parent (button, menu item, link) be + // the sole hit target so the cursor doesn't flicker as the pointer crosses + // painted vs. empty regions of the SVG. Consumers can re-enable via `style`. + pointerEvents: 'none', + ...style, + }} {...rest} > {paths[name]} diff --git a/ozone-ui/packages/shared/src/components/KeyValuePair/KeyValuePair.tsx b/ozone-ui/packages/shared/src/components/KeyValuePair/KeyValuePair.tsx index dba2a175be80..fea6d613e9c3 100644 --- a/ozone-ui/packages/shared/src/components/KeyValuePair/KeyValuePair.tsx +++ b/ozone-ui/packages/shared/src/components/KeyValuePair/KeyValuePair.tsx @@ -17,8 +17,9 @@ */ import React from 'react'; -import { Typography } from 'antd'; -import { semanticColors, spacing, textStyles } from '../../theme/tokens'; +import { Tooltip, Typography } from 'antd'; +import { InfoCircleOutlined } from '@ant-design/icons'; +import { colors, semanticColors, spacing, textStyles } from '../../theme/tokens'; export interface KeyValuePairProps { /** The label (key) text. */ @@ -33,6 +34,8 @@ export interface KeyValuePairProps { labelWidth?: number | string; /** Allow the value to be copied (adds an inline copy affordance). */ copyable?: boolean; + /** Optional help text shown via an info (i) icon next to the label. */ + tooltip?: React.ReactNode; style?: React.CSSProperties; } @@ -48,6 +51,7 @@ export const KeyValuePair: React.FC = ({ layout = 'vertical', labelWidth = 160, copyable = false, + tooltip, style, }) => { const isHorizontal = layout === 'horizontal'; @@ -55,6 +59,9 @@ export const KeyValuePair: React.FC = ({ const labelNode = ( = ({ }} > {label} + {tooltip && ( + + + + )} ); diff --git a/ozone-ui/packages/shared/src/components/SearchInput/SearchInput.tsx b/ozone-ui/packages/shared/src/components/SearchInput/SearchInput.tsx new file mode 100644 index 000000000000..7f70c88f1ffb --- /dev/null +++ b/ozone-ui/packages/shared/src/components/SearchInput/SearchInput.tsx @@ -0,0 +1,49 @@ +/** + * 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 from 'react'; +import { Input, type InputProps } from 'antd'; +import { semanticColors } from '../../theme/tokens'; +import Icon from '../Icon/Icon'; + +export interface SearchInputProps extends Omit { + /** Input width. Defaults to 256 (the "Input Field" width used in table toolbars). */ + width?: number | string; +} + +/** + * Text field with a leading search glyph, matching the "standard-text-field" + * search input used in the Ozone table toolbars. All standard Ant Design `Input` + * props are supported. + */ +export const SearchInput: React.FC = ({ + width = 256, + placeholder = 'Search...', + style, + ...rest +}) => ( + } + style={{ width, ...style }} + {...rest} + /> +); + +export default SearchInput; diff --git a/ozone-ui/packages/shared/src/components/Section/Section.tsx b/ozone-ui/packages/shared/src/components/Section/Section.tsx new file mode 100644 index 000000000000..c3837132a9ee --- /dev/null +++ b/ozone-ui/packages/shared/src/components/Section/Section.tsx @@ -0,0 +1,83 @@ +/** + * 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 from 'react'; +import { Typography } from 'antd'; +import { semanticColors, spacing, textStyles } from '../../theme/tokens'; + +export interface SectionProps { + /** Section heading. */ + title: React.ReactNode; + /** Optional supporting text rendered under the title (e.g. "High Availability"). */ + description?: React.ReactNode; + /** Right-aligned actions rendered on the header row. */ + actions?: React.ReactNode; + /** Section body. */ + children?: React.ReactNode; + style?: React.CSSProperties; +} + +/** + * A labelled content section: a title (with optional supporting text and + * right-aligned actions) followed by its content. Matches the "section-header" + * pattern that groups the cards and tables on the Ozone detail screens. + */ +export const Section: React.FC = ({ title, description, actions, children, style }) => ( +
+
+
+ + {title} + + {description && ( + + {description} + + )} +
+ {actions && ( +
{actions}
+ )} +
+ {children} +
+); + +export default Section; diff --git a/ozone-ui/packages/shared/src/components/Sidebar/Sidebar.tsx b/ozone-ui/packages/shared/src/components/Sidebar/Sidebar.tsx index 39b8815c7698..37fde62de1e6 100644 --- a/ozone-ui/packages/shared/src/components/Sidebar/Sidebar.tsx +++ b/ozone-ui/packages/shared/src/components/Sidebar/Sidebar.tsx @@ -17,10 +17,106 @@ */ import React, { useEffect, useState } from 'react'; -import { Layout, Menu, type MenuProps } from 'antd'; -import { DoubleLeftOutlined } from '@ant-design/icons'; +import { Layout, Menu, Space, type MenuProps } from 'antd'; import { useLocation, useNavigate } from 'react-router-dom'; import { MenuItem, findSelectedKey, getMenuItemPath } from '../../utils/menuUtils'; +import { radius, semanticColors, spacing } from '../../theme/tokens'; + +const CollapseIcon: React.FC<{ collapsed: boolean }> = ({ collapsed }) => ( + + + +); + +/** + * Mark the selected leaf item with a left accent bar and keep every other leaf + * aligned with a matching transparent border, so selection reads as a small + * indicator rather than a full-row highlight. + */ +const decorateItems = (items: MenuItem[], selectedKey: string | null): MenuItem[] => + items.map((item) => { + if (item.type === 'divider') { + return item; + } + if (item.children) { + return { ...item, children: decorateItems(item.children, selectedKey) }; + } + const isSelected = item.key != null && item.key === selectedKey; + return { + ...item, + style: { + // Square accent bar on the left, hover/selection pill rounded on the + // right only, with a small right inset so the rounding is visible. + borderLeft: `3px solid ${isSelected ? semanticColors.navIndicator : 'transparent'}`, + borderRadius: `0 ${radius.lg}px ${radius.lg}px 0`, + marginInlineEnd: spacing.sm, + ...item.style, + }, + }; + }); + +/** + * Split top-level items into sections at group boundaries (and any dividers), + * so each section renders as its own menu with vertical spacing (an antd + * `Space`) between them — matching the grouped rail in the design. + */ +const splitSections = (items: MenuItem[]): MenuItem[][] => { + const sections: MenuItem[][] = []; + let current: MenuItem[] = []; + const flush = () => { + if (current.length) { + sections.push(current); + current = []; + } + }; + for (const item of items) { + if (item.type === 'group') { + flush(); + sections.push([item]); + } else if (item.type === 'divider') { + flush(); + } else { + current.push(item); + } + } + flush(); + return sections; +}; + +/** + * Flatten to just the leaf items (dropping group wrappers/titles and dividers). + * Used when the rail is collapsed so it reads as a compact icon-only list with + * no section headings. + */ +const flattenLeaves = (items: MenuItem[]): MenuItem[] => { + const out: MenuItem[] = []; + for (const item of items) { + if (item.type === 'divider') { + continue; + } + if (item.children) { + out.push(...flattenLeaves(item.children)); + } else { + out.push(item); + } + } + return out; +}; export interface SidebarProps { /** Navigation items to render in the rail. Each item may carry a `path`. */ @@ -94,6 +190,11 @@ export const Sidebar: React.FC = ({ const branding = collapsed ? (collapsedLogo ?? logo) : logo; + // Collapsed: one compact icon-only list (no group titles or section gaps). + // Expanded: grouped sections spaced apart with an antd Space. + const decorated = decorateItems(items, selectedKey); + const sections = collapsed ? [flattenLeaves(decorated)] : splitSections(decorated); + return ( = ({ collapsedWidth={collapsedWidth} onCollapse={handleCollapse} width={width} - trigger={} + trigger={null} > - {branding} - +
+ {branding} + {/* One menu per section, spaced apart with an antd Space (vertical). */} + + {sections.map((section, index) => ( + + ))} + + {/* Left-aligned collapse trigger that blends into the rail surface. */} +
handleCollapse(!collapsed)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleCollapse(!collapsed); + } + }} + style={{ + flexShrink: 0, + display: 'flex', + alignItems: 'center', + // Keep the trigger left-aligned in both states so the chevron flips + // in place rather than sliding across as the rail width animates. + justifyContent: 'flex-start', + height: 48, + paddingInline: spacing.lg, + cursor: 'pointer', + color: semanticColors.navItemColor, + background: 'transparent', + userSelect: 'none', + }} + > + +
+
); }; diff --git a/ozone-ui/packages/shared/src/components/UtilityBar/UtilityBar.tsx b/ozone-ui/packages/shared/src/components/UtilityBar/UtilityBar.tsx index ebdce680bf47..ba354ac4b561 100644 --- a/ozone-ui/packages/shared/src/components/UtilityBar/UtilityBar.tsx +++ b/ozone-ui/packages/shared/src/components/UtilityBar/UtilityBar.tsx @@ -17,34 +17,75 @@ */ import React from 'react'; -import { colors, fontFamilies, semanticColors, spacing, textStyles } from '../../theme/tokens'; +import { BellOutlined, QuestionCircleOutlined, UserOutlined } from '@ant-design/icons'; +import { fontFamilies, semanticColors, spacing, textStyles } from '../../theme/tokens'; +import IconButton from '../IconButton/IconButton'; export interface UtilityBarProps { /** Left slot, e.g. an app switcher or menu button. */ leading?: React.ReactNode; - /** Product / app title shown next to the leading slot. */ + /** Product branding shown next to the leading slot (name/logo + host chip). */ + branding?: React.ReactNode; + /** @deprecated Use `branding`. Kept for back-compat; rendered when `branding` is unset. */ title?: React.ReactNode; /** Optional centre slot (e.g. global search). */ center?: React.ReactNode; - /** Right slot, e.g. notification/user icon buttons. */ + /** + * Right slot. When omitted, the bar renders the standard Help / Notifications + * / Profile actions (wire them up via the `on*` handlers below). + */ actions?: React.ReactNode; + /** Handler for the standard Help action (used when `actions` is not provided). */ + onHelp?: () => void; + /** Handler for the standard Notifications action. */ + onNotifications?: () => void; + /** Handler for the standard Profile action. */ + onProfile?: () => void; /** Height in px. Defaults to 48. */ height?: number; style?: React.CSSProperties; } /** - * Global top utility bar (the dark chrome at the very top of every screen). - * Provides leading/title, an optional centre slot and right-aligned actions. + * Global top utility bar (the app chrome at the very top of every screen). + * Provides a leading slot, product `branding`, an optional centre slot and + * right-aligned actions — defaulting to the standard Help / Notifications / + * Profile buttons when `actions` is not supplied. */ export const UtilityBar: React.FC = ({ leading, + branding, title, center, actions, + onHelp, + onNotifications, + onProfile, height = 48, style, -}) => ( +}) => { + const brand = branding ?? title; + const rightContent = actions ?? ( + <> + } + label="Help" + onClick={onHelp} + /> + } + label="Notifications" + onClick={onNotifications} + /> + } + label="Profile" + onClick={onProfile} + /> + + ); + + return (
= ({ gap: spacing.md, height, paddingInline: spacing.md, - background: colors.pewter[950], - color: 'rgb(255, 255, 255)', + background: semanticColors.bgTopbar, + color: semanticColors.textPrimary, ...style, }} >
{leading} - {title && ( + {brand && ( - {title} + {brand} )}
@@ -82,12 +123,13 @@ export const UtilityBar: React.FC = ({ display: 'flex', alignItems: 'center', gap: spacing.xs, - color: semanticColors.textDisabled, + color: semanticColors.textSecondary, }} > - {actions} + {rightContent}
-); + ); +}; export default UtilityBar; diff --git a/ozone-ui/packages/shared/src/index.ts b/ozone-ui/packages/shared/src/index.ts index 87e7994659cd..1d6963ce0663 100644 --- a/ozone-ui/packages/shared/src/index.ts +++ b/ozone-ui/packages/shared/src/index.ts @@ -30,8 +30,16 @@ export { default as PageHeader } from './components/PageHeader/PageHeader'; export type { PageHeaderProps } from './components/PageHeader/PageHeader'; export { default as Card } from './components/Card/Card'; export type { CardProps } from './components/Card/Card'; +export { default as Section } from './components/Section/Section'; +export type { SectionProps } from './components/Section/Section'; export { default as KeyValuePair } from './components/KeyValuePair/KeyValuePair'; export type { KeyValuePairProps } from './components/KeyValuePair/KeyValuePair'; +export { default as SearchInput } from './components/SearchInput/SearchInput'; +export type { SearchInputProps } from './components/SearchInput/SearchInput'; +export { default as DataTable } from './components/DataTable/DataTable'; +export type { DataTableProps } from './components/DataTable/DataTable'; +export { default as TablePagination } from './components/DataTable/TablePagination'; +export type { TablePaginationProps } from './components/DataTable/TablePagination'; export { default as Chip } from './components/Chip/Chip'; export type { ChipProps, ChipColor, ChipVariant, ChipSize } from './components/Chip/Chip'; export { default as Alert } from './components/Alert/Alert'; diff --git a/ozone-ui/packages/shared/src/theme/antdTheme.ts b/ozone-ui/packages/shared/src/theme/antdTheme.ts index 9d573ad4dc75..bfa466bc8431 100644 --- a/ozone-ui/packages/shared/src/theme/antdTheme.ts +++ b/ozone-ui/packages/shared/src/theme/antdTheme.ts @@ -17,7 +17,7 @@ */ import type { ThemeConfig } from 'antd'; -import { colors, fontFamilies, radius, semanticColors, textStyles } from './tokens'; +import { colors, fontFamilies, radius, semanticColors, spacing, textStyles } from './tokens'; /** * Ant Design v5 theme derived from the Ozone UI design tokens. @@ -72,25 +72,38 @@ export const ozoneTheme: ThemeConfig = { }, components: { Layout: { - headerBg: semanticColors.bgContainer, + headerBg: semanticColors.bgTopbar, headerColor: semanticColors.textPrimary, headerHeight: 56, headerPadding: '0 24px', bodyBg: semanticColors.bgLayout, - // The navigation rail is a deep pewter surface. - siderBg: colors.pewter[900], - triggerBg: colors.pewter[800], + // The navigation rail shares the light layout surface. + siderBg: semanticColors.bgSidebar, + // Collapse control shares the rail surface so it doesn't read as a + // separate section (the Sidebar renders its own left-aligned trigger). + triggerBg: semanticColors.bgSidebar, + triggerColor: semanticColors.navItemColor, }, Menu: { - darkItemBg: colors.pewter[900], - darkSubMenuItemBg: colors.pewter[950], - darkItemSelectedBg: colors.orange[400], - darkItemSelectedColor: 'rgb(255, 255, 255)', - darkItemColor: colors.pewter[200], - darkItemHoverBg: colors.pewter[800], - itemBorderRadius: radius.md, - itemSelectedBg: colors.orange[50], - itemSelectedColor: colors.orange[500], + // Light rail: no full-row selection fill — a left accent bar (applied per + // item in the Sidebar) marks the active item instead. + itemBg: 'transparent', + subMenuItemBg: 'transparent', + itemColor: semanticColors.navItemColor, + itemSelectedBg: 'transparent', + itemSelectedColor: semanticColors.navItemColorSelected, + itemHoverBg: semanticColors.navItemBgHover, + itemHoverColor: semanticColors.navItemColorHover, + itemActiveBg: semanticColors.navItemBgHover, + groupTitleColor: semanticColors.navGroupTitleColor, + // Radius/margins are applied per item in the Sidebar so the hover/selection + // pill rounds on the right only (the left edge carries the accent bar). + itemBorderRadius: 0, + itemMarginInline: 0, + itemMarginBlock: spacing.xs, + // Suppress Ant Design's built-in inline selection border. + activeBarWidth: 0, + activeBarBorderWidth: 0, }, Card: { borderRadiusLG: radius.lg, diff --git a/ozone-ui/packages/shared/src/theme/tokens.ts b/ozone-ui/packages/shared/src/theme/tokens.ts index 2034d93bd167..dfa728763804 100644 --- a/ozone-ui/packages/shared/src/theme/tokens.ts +++ b/ozone-ui/packages/shared/src/theme/tokens.ts @@ -108,6 +108,18 @@ export const colors = { 800: 'rgb(70, 53, 0)', 900: 'rgb(41, 30, 0)', }, + pear: { + 50: 'rgb(236, 253, 195)', + 100: 'rgb(218, 251, 110)', + 200: 'rgb(196, 233, 36)', + 300: 'rgb(180, 214, 32)', // base (brand primary) + 400: 'rgb(151, 180, 25)', + 500: 'rgb(125, 149, 19)', + 600: 'rgb(97, 117, 12)', + 700: 'rgb(73, 88, 7)', + 800: 'rgb(48, 59, 3)', + 900: 'rgb(27, 34, 1)', + }, } as const; /** @@ -115,9 +127,9 @@ export const colors = { * intent is explicit and re-theming stays centralised. */ export const semanticColors = { - brand: colors.orange[400], - brandHover: colors.orange[300], - brandActive: colors.orange[500], + brand: colors.green[500], + brandHover: colors.green[400], + brandActive: colors.green[600], info: colors.blueNova[600], success: colors.green[700], warning: colors.amber[300], @@ -133,6 +145,19 @@ export const semanticColors = { bgElevated: 'rgb(255, 255, 255)', fill: colors.pewter[50], skeleton: colors.pewter[50], + // App chrome. The top utility bar and navigation rail share the light layout + // surface so the whole shell reads as one continuous background. + bgTopbar: colors.pewter[25], + bgSidebar: colors.pewter[25], + // Navigation rail item colours (light theme). + navItemColor: colors.pewter[600], + navItemColorSelected: colors.pewter[950], + navItemColorHover: colors.pewter[900], + navItemBgHover: colors.pewter[50], + navIconColor: colors.pewter[400], + navGroupTitleColor: colors.pewter[600], + /** The 3px accent bar marking the selected navigation item (brand primary). */ + navIndicator: colors.green[500], } as const; /** Font families. Roboto is the primary UI face; app titles use Plus Jakarta Sans. */ diff --git a/ozone-ui/packages/shared/src/utils/menuUtils.ts b/ozone-ui/packages/shared/src/utils/menuUtils.ts index 92a0773aa8c7..0a123e6f3b10 100644 --- a/ozone-ui/packages/shared/src/utils/menuUtils.ts +++ b/ozone-ui/packages/shared/src/utils/menuUtils.ts @@ -18,10 +18,14 @@ import React from 'react'; export type MenuItem = { - key: string; - label: string; + key?: string; + label?: string; path?: string; icon?: React.ReactNode; + /** `group` renders a non-clickable section label; `divider` a separator line. */ + type?: 'group' | 'divider'; + /** Inline style forwarded to the underlying Ant Design menu item. */ + style?: React.CSSProperties; children?: MenuItem[]; }; @@ -47,8 +51,8 @@ export const findSelectedKey = ( header: string | null; } => { for (const item of items) { - if (item.path === pathname) { - return { selectedKey: item.key, header: item.label }; + if (item.path !== undefined && item.path === pathname) { + return { selectedKey: item.key ?? null, header: item.label ?? null }; } if (item.children) { const result = findSelectedKey(item.children, pathname); From ebe71f64b754ffe6b6a2965282833d4bd292879d Mon Sep 17 00:00:00 2001 From: Abhishek Pal Date: Thu, 30 Jul 2026 14:21:24 +0530 Subject: [PATCH 2/5] Fix lint issues --- ozone-ui/packages/om/mock/jmxData.cjs | 3 +- ozone-ui/packages/om/mock/server.cjs | 2 +- ozone-ui/packages/om/package.json | 1 + ozone-ui/packages/om/src/App.tsx | 5 +- ozone-ui/packages/om/src/api/overview.ts | 52 ++++++++--- ozone-ui/packages/om/src/api/useJmx.ts | 8 +- ozone-ui/packages/om/src/navigation.tsx | 21 ++++- .../sections/InstanceDetailsSection.tsx | 9 +- .../pages/Overview/sections/JvmSection.tsx | 31 ++++--- .../sections/MetadataVolumeSection.tsx | 9 +- .../pages/Overview/sections/RolesSection.tsx | 9 +- .../src/components/AppLayout/AppLayout.tsx | 92 +++++++++---------- .../src/components/DataTable/DataTable.tsx | 24 ++--- .../components/DataTable/TablePagination.tsx | 4 +- .../shared/src/components/Icon/Icon.tsx | 4 +- .../shared/src/components/Section/Section.tsx | 8 +- .../src/components/UtilityBar/UtilityBar.tsx | 72 +++++++-------- 17 files changed, 204 insertions(+), 150 deletions(-) diff --git a/ozone-ui/packages/om/mock/jmxData.cjs b/ozone-ui/packages/om/mock/jmxData.cjs index af76ac068d47..8a92aeed4db8 100644 --- a/ozone-ui/packages/om/mock/jmxData.cjs +++ b/ozone-ui/packages/om/mock/jmxData.cjs @@ -44,8 +44,7 @@ const runtime = { VmName: 'OpenJDK 64-Bit Server VM', VmVendor: 'AdoptOpenJDK', VmVersion: '25.232-b09', - LibraryPath: - ':/opt/ozone/current/lib/hadoop-ozone/share/ozone/lib', + LibraryPath: ':/opt/ozone/current/lib/hadoop-ozone/share/ozone/lib', Uptime: 78876304, ManagementSpecVersion: '1.2', SpecName: 'Java Virtual Machine Specification', diff --git a/ozone-ui/packages/om/mock/server.cjs b/ozone-ui/packages/om/mock/server.cjs index 246acd2b2c65..552988bcc771 100644 --- a/ozone-ui/packages/om/mock/server.cjs +++ b/ozone-ui/packages/om/mock/server.cjs @@ -25,6 +25,7 @@ * server, which proxies /jmx to it). */ +/* eslint-disable @typescript-eslint/no-require-imports */ const jsonServer = require('json-server'); const jmxData = require('./jmxData.cjs'); @@ -41,6 +42,5 @@ server.get('/jmx', (req, res) => { }); server.listen(PORT, () => { - // eslint-disable-next-line no-console console.log(`OM JMX mock listening on http://localhost:${PORT}/jmx?qry=...`); }); diff --git a/ozone-ui/packages/om/package.json b/ozone-ui/packages/om/package.json index ec4397d6f9cd..1de122729fde 100644 --- a/ozone-ui/packages/om/package.json +++ b/ozone-ui/packages/om/package.json @@ -2,6 +2,7 @@ "name": "@ozone-ui/ozone-om", "private": true, "version": "1.0.0", + "type": "module", "scripts": { "dev": "vite --port=3000", "mock:om": "node mock/server.cjs", diff --git a/ozone-ui/packages/om/src/App.tsx b/ozone-ui/packages/om/src/App.tsx index ea66ad252725..0c0e61e26e25 100644 --- a/ozone-ui/packages/om/src/App.tsx +++ b/ozone-ui/packages/om/src/App.tsx @@ -53,10 +53,7 @@ const utilityBar = ( function App() { return ( - } - > + }> } /> } /> diff --git a/ozone-ui/packages/om/src/api/overview.ts b/ozone-ui/packages/om/src/api/overview.ts index 6c25fbc9ef0e..f244bfe1e5cc 100644 --- a/ozone-ui/packages/om/src/api/overview.ts +++ b/ozone-ui/packages/om/src/api/overview.ts @@ -132,7 +132,9 @@ export function parseRatisRoles(raw: string, currentNodeId?: string): RatisRole[ .split('|') .forEach((part) => { const sep = part.indexOf(':'); - if (sep === -1) return; + if (sep === -1) { + return; + } fields[part.slice(0, sep).trim()] = part.slice(sep + 1).trim(); }); const role = (fields.Role ?? '').toUpperCase(); @@ -166,8 +168,12 @@ function splitArgument(arg: string): { parameter: string; value: string } { } if (arg.startsWith('-XX:')) { const body = arg.slice(4); - if (body.startsWith('+')) return { parameter: arg, value: 'Enabled' }; - if (body.startsWith('-')) return { parameter: arg, value: 'Disabled' }; + if (body.startsWith('+')) { + return { parameter: arg, value: 'Enabled' }; + } + if (body.startsWith('-')) { + return { parameter: arg, value: 'Disabled' }; + } const eq = body.indexOf('='); return eq === -1 ? { parameter: arg, value: 'Present' } @@ -204,9 +210,13 @@ export function toSystemPropertyRows(props: SystemProperty[]): JvmParameter[] { } function formatHeap(xmx: string | undefined): string { - if (!xmx) return 'Not set'; + if (!xmx) { + return 'Not set'; + } const match = xmx.slice(4).match(/^(\d+)\s*([kKmMgG])?/); - if (!match) return xmx.slice(4); + if (!match) { + return xmx.slice(4); + } const size = Number(match[1]); const unit = (match[2] ?? 'B').toUpperCase(); const megabytes = unit === 'G' ? size * 1024 : unit === 'K' ? Math.round(size / 1024) : size; @@ -215,11 +225,21 @@ function formatHeap(xmx: string | undefined): string { function detectGarbageCollector(args: string[]): string { const flags = args.join(' '); - if (/UseG1GC/.test(flags)) return 'G1GC'; - if (/UseConcMarkSweepGC/.test(flags)) return 'ConcMarkSweep (CMS)'; - if (/UseParallelGC/.test(flags)) return 'Parallel'; - if (/UseZGC/.test(flags)) return 'ZGC'; - if (/UseShenandoahGC/.test(flags)) return 'Shenandoah'; + if (/UseG1GC/.test(flags)) { + return 'G1GC'; + } + if (/UseConcMarkSweepGC/.test(flags)) { + return 'ConcMarkSweep (CMS)'; + } + if (/UseParallelGC/.test(flags)) { + return 'Parallel'; + } + if (/UseZGC/.test(flags)) { + return 'ZGC'; + } + if (/UseShenandoahGC/.test(flags)) { + return 'Shenandoah'; + } return 'Default'; } @@ -275,13 +295,19 @@ export function formatStarted(millis: number): string { * "12 hours 40 mins", or "5 mins". Returns "—" for missing/negative input. */ export function formatElapsed(millis: number | undefined): string { - if (!millis || millis < 0) return '—'; + if (!millis || millis < 0) { + return '—'; + } const totalMinutes = Math.floor(millis / 60000); const days = Math.floor(totalMinutes / 1440); const hours = Math.floor((totalMinutes % 1440) / 60); const mins = totalMinutes % 60; const unit = (n: number, name: string) => `${n} ${name}${n === 1 ? '' : 's'}`; - if (days > 0) return `${unit(days, 'day')} ${unit(hours, 'hour')}`; - if (hours > 0) return `${unit(hours, 'hour')} ${unit(mins, 'min')}`; + if (days > 0) { + return `${unit(days, 'day')} ${unit(hours, 'hour')}`; + } + if (hours > 0) { + return `${unit(hours, 'hour')} ${unit(mins, 'min')}`; + } return unit(mins, 'min'); } diff --git a/ozone-ui/packages/om/src/api/useJmx.ts b/ozone-ui/packages/om/src/api/useJmx.ts index 02ab2d0b77bd..125670e28963 100644 --- a/ozone-ui/packages/om/src/api/useJmx.ts +++ b/ozone-ui/packages/om/src/api/useJmx.ts @@ -39,10 +39,14 @@ export function useJmxBean(qry: string, refreshToken = 0): JmxBeanState { setState({ loading: true }); fetchJmxBean(qry) .then((data) => { - if (active) setState({ data, loading: false }); + if (active) { + setState({ data, loading: false }); + } }) .catch((error: Error) => { - if (active) setState({ loading: false, error }); + if (active) { + setState({ loading: false, error }); + } }); return () => { active = false; diff --git a/ozone-ui/packages/om/src/navigation.tsx b/ozone-ui/packages/om/src/navigation.tsx index 2e486226230b..b4b976056f13 100644 --- a/ozone-ui/packages/om/src/navigation.tsx +++ b/ozone-ui/packages/om/src/navigation.tsx @@ -46,14 +46,24 @@ const navItem = (key: string, label: string, path: string, icon: MenuItem['icon' */ export const navItems: MenuItem[] = [ navItem('overview', 'Overview', '/', ), - navItem('configuration', 'Configuration', '/configuration', ), + navItem( + 'configuration', + 'Configuration', + '/configuration', + + ), { type: 'group', key: 'group-diagnostics', label: 'Diagnostics', children: [ navItem('rpc', 'Remote Procedure Call', '/rpc', ), - navItem('ozone-manager', 'Ozone Manager', '/ozone-manager', ), + navItem( + 'ozone-manager', + 'Ozone Manager', + '/ozone-manager', + + ), navItem('jmx', 'JMX', '/jmx-info', ), navItem('stacks', 'Stacks', '/stacks', ), ], @@ -63,7 +73,12 @@ export const navItems: MenuItem[] = [ key: 'group-links', label: 'Links', children: [ - navItem('documentation', 'Documentation', '/documentation', ), + navItem( + 'documentation', + 'Documentation', + '/documentation', + + ), navItem('log-levels', 'Log levels', '/log-levels', ), ], }, diff --git a/ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx b/ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx index 80e133451ab2..2e219ddf9999 100644 --- a/ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx +++ b/ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx @@ -43,10 +43,11 @@ export interface SectionProps { * the Roles and Metadata Volume sections) plus this node's Ratis bean. */ export const InstanceDetailsSection: React.FC = ({ refreshToken }) => { - const { data: omInfo, loading, error } = useJmxBean( - JMX_QUERY.omInfo, - refreshToken - ); + const { + data: omInfo, + loading, + error, + } = useJmxBean(JMX_QUERY.omInfo, refreshToken); const { data: ratis } = useJmxBean(JMX_QUERY.ratisServer, refreshToken); const currentHost = omInfo diff --git a/ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx b/ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx index 84940ae92b83..5057ccc6b55c 100644 --- a/ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx +++ b/ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx @@ -51,11 +51,7 @@ const monospace: React.CSSProperties = { }; const escapeXml = (s: string) => - s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); /** Render parameter rows as a Hadoop-style XML configuration snippet. */ const buildConfigXml = (params: JvmParameter[]): string => { @@ -106,10 +102,11 @@ const columns: TableColumnsType = [ * fetched lazily only when this section renders. */ export const JvmSection: React.FC = ({ refreshToken }) => { - const { data: runtime, loading, error } = useJmxBean( - JMX_QUERY.runtime, - refreshToken - ); + const { + data: runtime, + loading, + error, + } = useJmxBean(JMX_QUERY.runtime, refreshToken); const [search, setSearch] = useState(''); const [category, setCategory] = useState<'All' | JvmParameterCategory>('All'); @@ -119,7 +116,9 @@ export const JvmSection: React.FC = ({ refreshToken }) => { const highlights = useMemo(() => (runtime ? buildJvmHighlights(runtime) : []), [runtime]); const allRows = useMemo(() => { - if (!runtime) return []; + if (!runtime) { + return []; + } const args = parseJvmArguments(runtime.InputArguments); return showModules ? [...args, ...toSystemPropertyRows(runtime.SystemProperties)] : args; }, [runtime, showModules]); @@ -127,8 +126,12 @@ export const JvmSection: React.FC = ({ refreshToken }) => { const rows = useMemo(() => { const needle = search.trim().toLowerCase(); return allRows.filter((row) => { - if (category !== 'All' && row.category !== category) return false; - if (!needle) return true; + if (category !== 'All' && row.category !== category) { + return false; + } + if (!needle) { + return true; + } return ( row.parameter.toLowerCase().includes(needle) || row.value.toLowerCase().includes(needle) ); @@ -156,7 +159,9 @@ export const JvmSection: React.FC = ({ refreshToken }) => { const chosen = selectedRowKeys.length ? allRows.filter((r) => selectedRowKeys.includes(r.key)) : rows; - if (!chosen.length) return; + if (!chosen.length) { + return; + } await navigator.clipboard.writeText(buildConfigXml(chosen)); message.success( `Copied ${chosen.length} ${chosen.length === 1 ? 'parameter' : 'parameters'} as XML` diff --git a/ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx b/ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx index 1682e997d9c4..38931df9dcc4 100644 --- a/ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx +++ b/ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx @@ -31,10 +31,11 @@ const gridStyle: React.CSSProperties = { /** "Metadata Volume Information" card. Sourced from the OM ServerRuntime bean. */ export const MetadataVolumeSection: React.FC = ({ refreshToken }) => { - const { data: omInfo, loading, error } = useJmxBean( - JMX_QUERY.omInfo, - refreshToken - ); + const { + data: omInfo, + loading, + error, + } = useJmxBean(JMX_QUERY.omInfo, refreshToken); return (
diff --git a/ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx b/ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx index b50c5b524496..cf85bc091ee4 100644 --- a/ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx +++ b/ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx @@ -81,10 +81,11 @@ const columns: TableColumnsType = [ /** "Ozone Manager Roles" HA table. Sourced from the OM ServerRuntime bean. */ export const RolesSection: React.FC = ({ refreshToken }) => { - const { data: omInfo, loading, error } = useJmxBean( - JMX_QUERY.omInfo, - refreshToken - ); + const { + data: omInfo, + loading, + error, + } = useJmxBean(JMX_QUERY.omInfo, refreshToken); const { data: ratis } = useJmxBean(JMX_QUERY.ratisServer, refreshToken); const { data: electionCount } = useJmxBean( JMX_QUERY.leaderElectionCount, diff --git a/ozone-ui/packages/shared/src/components/AppLayout/AppLayout.tsx b/ozone-ui/packages/shared/src/components/AppLayout/AppLayout.tsx index 12272016f883..3d4f48c9acc6 100644 --- a/ozone-ui/packages/shared/src/components/AppLayout/AppLayout.tsx +++ b/ozone-ui/packages/shared/src/components/AppLayout/AppLayout.tsx @@ -59,55 +59,55 @@ export const AppLayout: React.FC = ({ {sider} {/* Breathing room between the navigation rail and the content column. */} - {(title || headerExtra) && ( -
+ {typeof title === 'string' ? ( + + {title} + + ) : ( + title + )} + {headerExtra && ( +
+ {headerExtra} +
+ )} +
+ )} + - {typeof title === 'string' ? ( - - {title} - - ) : ( - title - )} - {headerExtra && ( -
- {headerExtra} -
- )} -
- )} - -
- {children} -
+
+ {children} +
diff --git a/ozone-ui/packages/shared/src/components/DataTable/DataTable.tsx b/ozone-ui/packages/shared/src/components/DataTable/DataTable.tsx index 4062527df671..3e9d96e47779 100644 --- a/ozone-ui/packages/shared/src/components/DataTable/DataTable.tsx +++ b/ozone-ui/packages/shared/src/components/DataTable/DataTable.tsx @@ -75,8 +75,15 @@ export function DataTable({ rowExpandable ? ( onExpand(record, e)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onExpand(record, e as unknown as React.MouseEvent); + } + }} style={{ display: 'inline-flex', cursor: 'pointer', color: semanticColors.textSecondary }} > @@ -85,11 +92,9 @@ export function DataTable({ ); - const mergedExpandable = expandable - ? { expandIcon: themedExpandIcon, ...expandable } - : undefined; + const mergedExpandable = expandable ? { expandIcon: themedExpandIcon, ...expandable } : undefined; - const rows = dataSource ?? []; + const rows = useMemo(() => dataSource ?? [], [dataSource]); const total = rows.length; // Return to the first page whenever the row set changes (e.g. search/filter), @@ -151,7 +156,9 @@ export function DataTable({ gap: spacing.lg, }} > -
+
{filters}
{actions && ( @@ -164,12 +171,7 @@ export function DataTable({
)} - - dataSource={pageRows} - pagination={false} - expandable={mergedExpandable} - {...rest} - /> + dataSource={pageRows} pagination={false} expandable={mergedExpandable} {...rest} /> {paginated && (
diff --git a/ozone-ui/packages/shared/src/components/DataTable/TablePagination.tsx b/ozone-ui/packages/shared/src/components/DataTable/TablePagination.tsx index 1a9c3d14f61e..49d7b3681108 100644 --- a/ozone-ui/packages/shared/src/components/DataTable/TablePagination.tsx +++ b/ozone-ui/packages/shared/src/components/DataTable/TablePagination.tsx @@ -97,9 +97,7 @@ export const TablePagination: React.FC = ({ />
- - {`Showing ${from}-${to} of ${total}`} - + {`Showing ${from}-${to} of ${total}`}
= { strokeLinejoin="round" /> ), - grid: ( - - ), + grid: , help: ( = ({ title, description, actions, children, style }) => ( +export const Section: React.FC = ({ + title, + description, + actions, + children, + style, +}) => (
= ({ ); return ( -
-
- {leading} - {brand && ( - - {brand} - - )} -
- - {center &&
{center}
} -
- {rightContent} +
+ {leading} + {brand && ( + + {brand} + + )} +
+ + {center &&
{center}
} + +
+ {rightContent} +
-
); }; From f9aa3a9267fc65bff3d04e6e8945191b550ab507 Mon Sep 17 00:00:00 2001 From: Abhishek Pal Date: Mon, 3 Aug 2026 14:22:31 +0530 Subject: [PATCH 3/5] Migrate to TanStack Query, fix RatisRoles type, address size conversion bug --- ozone-ui/package.json | 1 - ozone-ui/packages/om/mock/jmxData.cjs | 9 +- ozone-ui/packages/om/package.json | 6 +- .../om/src/__tests__/overview.parsers.test.ts | 193 ++++++++++++++++++ .../packages/om/src/__tests__/vitest.setup.ts | 19 ++ ozone-ui/packages/om/src/api/jmx.ts | 49 +---- ozone-ui/packages/om/src/api/overview.ts | 77 ++++--- ozone-ui/packages/om/src/api/useJmx.ts | 68 +++--- ozone-ui/packages/om/src/main.tsx | 14 +- .../om/src/pages/Overview/OverviewPage.tsx | 25 +-- .../om/src/pages/Overview/SectionBody.tsx | 14 +- .../sections/InstanceDetailsSection.tsx | 20 +- .../pages/Overview/sections/JvmSection.tsx | 16 +- .../sections/MetadataVolumeSection.tsx | 15 +- .../pages/Overview/sections/RolesSection.tsx | 73 ++++--- ozone-ui/packages/om/vite.config.ts | 20 +- ozone-ui/packages/recon/package.json | 1 - ozone-ui/packages/recon/vite.config.ts | 4 +- ozone-ui/packages/scm/package.json | 1 - ozone-ui/packages/scm/vite.config.ts | 4 +- ozone-ui/packages/shared/package.json | 2 + .../shared/src/data/QueryProvider.tsx | 40 ++++ .../packages/shared/src/data/fetchJson.ts | 84 ++++++++ .../packages/shared/src/data/queryClient.ts | 44 ++++ ozone-ui/packages/shared/src/index.ts | 7 + ozone-ui/pnpm-lock.yaml | 73 +++---- 26 files changed, 653 insertions(+), 226 deletions(-) create mode 100644 ozone-ui/packages/om/src/__tests__/overview.parsers.test.ts create mode 100644 ozone-ui/packages/om/src/__tests__/vitest.setup.ts create mode 100644 ozone-ui/packages/shared/src/data/QueryProvider.tsx create mode 100644 ozone-ui/packages/shared/src/data/fetchJson.ts create mode 100644 ozone-ui/packages/shared/src/data/queryClient.ts diff --git a/ozone-ui/package.json b/ozone-ui/package.json index 7f095df71e28..9ffdbeda4c06 100644 --- a/ozone-ui/package.json +++ b/ozone-ui/package.json @@ -29,7 +29,6 @@ "@ant-design/icons": "^5.6.1", "@fontsource/roboto": "^4.5.8", "antd": "^5.24.3", - "axios": "^1.9.0", "less": "^4.2.2", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/ozone-ui/packages/om/mock/jmxData.cjs b/ozone-ui/packages/om/mock/jmxData.cjs index 8a92aeed4db8..40c14fa30efa 100644 --- a/ozone-ui/packages/om/mock/jmxData.cjs +++ b/ozone-ui/packages/om/mock/jmxData.cjs @@ -27,8 +27,13 @@ const ozoneManagerInfo = { name: 'Hadoop:service=OzoneManager,name=OzoneManagerInfo,component=ServerRuntime', modelerType: 'org.apache.hadoop.ozone.om.OzoneManager', RpcPort: '9862', - RatisRoles: - ' { HostName: node1.test.site.com | Node-Id: om1546336043 | Ratis-Port : 9872 | Role: FOLLOWER} { HostName: node2.test.site.com | Node-Id: om1546336047 | Ratis-Port : 9872 | Role: LEADER} { HostName: node3.test.site.com | Node-Id: om1546336039 | Ratis-Port : 9872 | Role: FOLLOWER} ', + // Array of [hostName, nodeId, ratisPort, role, leaderReadiness] tuples, matching + // OMMXBean.getRatisRoles() (List>) on a real OM. + RatisRoles: [ + ['node1.test.site.com', 'om1546336043', '9872', 'FOLLOWER', 'LEADER_AND_READY'], + ['node2.test.site.com', 'om1546336047', '9872', 'LEADER', 'LEADER_AND_READY'], + ['node3.test.site.com', 'om1546336039', '9872', 'FOLLOWER', 'LEADER_AND_READY'], + ], RatisLogDirectory: '/var/lib/hadoop-ozone/om/ratis', RocksDbDirectory: '/var/lib/hadoop-ozone/om/data', Version: '2.3.0, r0a1b2c3d4e5f60718293a4b5c6d7e8f901234567', diff --git a/ozone-ui/packages/om/package.json b/ozone-ui/packages/om/package.json index 1de122729fde..f76f07417aa7 100644 --- a/ozone-ui/packages/om/package.json +++ b/ozone-ui/packages/om/package.json @@ -8,16 +8,18 @@ "mock:om": "node mock/server.cjs", "dev:om:mock": "npm-run-all -p mock:om dev", "build": "vite build", - "lint": "eslint ." + "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@ozone-ui/shared": "workspace:*", "@ant-design/icons": "^5.6.1", "@fontsource/roboto": "^4.5.8", + "@tanstack/react-query": "^5.62.0", "ag-charts-community": "^7.3.0", "ag-charts-react": "^7.3.0", "antd": "^5.24.3", - "axios": "^1.9.0", "classnames": "^2.3.2", "echarts": "^5.5.0", "filesize": "^6.4.0", diff --git a/ozone-ui/packages/om/src/__tests__/overview.parsers.test.ts b/ozone-ui/packages/om/src/__tests__/overview.parsers.test.ts new file mode 100644 index 000000000000..44b6117a54fa --- /dev/null +++ b/ozone-ui/packages/om/src/__tests__/overview.parsers.test.ts @@ -0,0 +1,193 @@ +/** + * 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 { + buildJvmHighlights, + formatElapsed, + formatStarted, + parseJvmArguments, + parseRatisRoles, + toSystemPropertyRows, + type RuntimeBean, +} from '../api/overview'; + +describe('parseRatisRoles', () => { + const rows = [ + ['host-a.example.com', 'om-a', '9872', 'FOLLOWER', 'LEADER_AND_READY'], + ['host-b.example.com', 'om-b', '9872', 'LEADER', 'LEADER_AND_READY'], + ]; + + it('maps [host, nodeId, port, role, readiness] tuples', () => { + const [a, b] = parseRatisRoles(rows); + expect(a).toMatchObject({ + hostName: 'host-a.example.com', + nodeId: 'om-a', + ratisPort: '9872', + role: 'FOLLOWER', + readiness: 'Synced', + isCurrent: false, + }); + // The leader row has no readiness. + expect(b).toMatchObject({ role: 'LEADER', readiness: null }); + }); + + it('flags the current node by id', () => { + const parsed = parseRatisRoles(rows, 'om-b'); + expect(parsed.find((r) => r.isCurrent)?.nodeId).toBe('om-b'); + expect(parsed.filter((r) => r.isCurrent)).toHaveLength(1); + }); + + it('uppercases the role', () => { + const [role] = parseRatisRoles([['h', 'n', '9872', 'follower']]); + expect(role.role).toBe('FOLLOWER'); + }); + + it('skips malformed rows such as the single-element error row', () => { + const parsed = parseRatisRoles([['No leader found in the cluster'], rows[0]]); + expect(parsed).toHaveLength(1); + expect(parsed[0].nodeId).toBe('om-a'); + }); + + it('returns an empty array for undefined input (no throw)', () => { + expect(parseRatisRoles(undefined)).toEqual([]); + }); +}); + +describe('parseJvmArguments', () => { + it('splits the supported argument shapes', () => { + const rows = parseJvmArguments([ + '-Dproc_om', + '-Dhdp.version=7.3.2', + '-XX:+UseG1GC', + '-XX:-UseParallelGC', + '-XX:MaxGCPauseMillis=200', + '-Xmx4096m', + '-Xss256k', + '-Xloggc:/var/log/gc.log', + '-verbose:gc', + 'com.example.Main', + ]); + expect(rows.map(({ parameter, value }) => ({ parameter, value }))).toEqual([ + { parameter: '-Dproc_om', value: 'Present' }, + { parameter: '-Dhdp.version', value: '7.3.2' }, + { parameter: '-XX:+UseG1GC', value: 'Enabled' }, + { parameter: '-XX:-UseParallelGC', value: 'Disabled' }, + { parameter: '-XX:MaxGCPauseMillis', value: '200' }, + { parameter: '-Xmx', value: '4096m' }, + { parameter: '-Xss', value: '256k' }, + { parameter: '-Xloggc', value: '/var/log/gc.log' }, + { parameter: '-verbose', value: 'gc' }, + { parameter: 'com.example.Main', value: 'Present' }, + ]); + }); + + it('categorizes memory/GC flags separately from system flags', () => { + const rows = parseJvmArguments(['-Xmx4096m', '-XX:+UseG1GC', '-Dproc_om']); + expect(rows.map((r) => r.category)).toEqual([ + 'Memory & GC', + 'Memory & GC', + 'System & Framework', + ]); + }); + + it('returns an empty array for undefined input', () => { + expect(parseJvmArguments(undefined as unknown as string[])).toEqual([]); + }); +}); + +describe('toSystemPropertyRows', () => { + it('maps properties and renders blank values as a dash', () => { + const rows = toSystemPropertyRows([ + { key: 'java.version', value: '17.0.11' }, + { key: 'sun.arch.data.model', value: '' }, + ]); + expect(rows).toEqual([ + { key: 'prop-0', parameter: 'java.version', value: '17.0.11', category: 'System Property' }, + { key: 'prop-1', parameter: 'sun.arch.data.model', value: '—', category: 'System Property' }, + ]); + }); +}); + +describe('buildJvmHighlights', () => { + const runtime = (args: string[], props: Record = {}): RuntimeBean => ({ + VmName: 'OpenJDK 64-Bit Server VM', + VmVendor: 'Eclipse Adoptium', + VmVersion: '17.0.11+9', + Name: '123@host', + InputArguments: args, + SystemProperties: Object.entries(props).map(([key, value]) => ({ key, value })), + }); + + const heapValue = (args: string[]) => + buildJvmHighlights(runtime(args)).find((h) => h.key === 'heap')?.value; + + it('formats heap sizes with explicit units', () => { + expect(heapValue(['-Xmx4g'])).toBe('4,096 MB'); + expect(heapValue(['-Xmx4096m'])).toBe('4,096 MB'); + expect(heapValue(['-Xmx524288k'])).toBe('512 MB'); + }); + + it('treats a bare -Xmx value as bytes (the reported bug)', () => { + // 2,511,000,000 bytes / 1024² ≈ 2,395 MB — not "2,511,000,000 MB". + expect(heapValue(['-Xmx2511000000'])).toBe('2,395 MB'); + }); + + it('reports "Not set" when no -Xmx flag is present', () => { + expect(heapValue(['-XX:+UseG1GC'])).toBe('Not set'); + }); + + it('detects the garbage collector and GC pause target', () => { + const highlights = buildJvmHighlights(runtime(['-XX:+UseG1GC', '-XX:MaxGCPauseMillis=200'])); + expect(highlights.find((h) => h.key === 'gc')?.value).toBe('G1GC'); + expect(highlights.find((h) => h.key === 'gcPause')?.value).toBe('200 ms'); + }); + + it('builds the runtime-environment label from system properties', () => { + const highlights = buildJvmHighlights( + runtime([], { 'java.runtime.name': 'OpenJDK Runtime Environment', 'java.version': '17.0.11' }) + ); + expect(highlights.find((h) => h.key === 'runtime')?.value).toBe( + 'OpenJDK Runtime Environment 17.0.11' + ); + }); +}); + +describe('formatStarted', () => { + it('formats an epoch-millis timestamp as a readable date', () => { + // Assert the shape rather than an exact string to stay timezone-independent. + expect(formatStarted(1785178223133)).toMatch( + /^[A-Z][a-z]{2} \d{1,2}, \d{4} \d{1,2}:\d{2}:\d{2} [AP]M$/ + ); + }); +}); + +describe('formatElapsed', () => { + it('returns a dash for missing or negative input', () => { + expect(formatElapsed(undefined)).toBe('—'); + expect(formatElapsed(-1)).toBe('—'); + }); + + it('formats minutes, hours and days with correct pluralization', () => { + expect(formatElapsed(1 * 60_000)).toBe('1 min'); + expect(formatElapsed(5 * 60_000)).toBe('5 mins'); + expect(formatElapsed((2 * 60 + 40) * 60_000)).toBe('2 hours 40 mins'); + expect(formatElapsed(25 * 60 * 60_000)).toBe('1 day 1 hour'); + expect(formatElapsed(48 * 60 * 60_000)).toBe('2 days 0 hours'); + }); +}); diff --git a/ozone-ui/packages/om/src/__tests__/vitest.setup.ts b/ozone-ui/packages/om/src/__tests__/vitest.setup.ts new file mode 100644 index 000000000000..f984872e8e64 --- /dev/null +++ b/ozone-ui/packages/om/src/__tests__/vitest.setup.ts @@ -0,0 +1,19 @@ +/** + * 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 '@testing-library/jest-dom'; diff --git a/ozone-ui/packages/om/src/api/jmx.ts b/ozone-ui/packages/om/src/api/jmx.ts index c4af126c6b6b..d8107a83775d 100644 --- a/ozone-ui/packages/om/src/api/jmx.ts +++ b/ozone-ui/packages/om/src/api/jmx.ts @@ -16,58 +16,25 @@ * limitations under the License. */ -import axios from 'axios'; +import { fetchJson } from '@ozone-ui/shared'; /** * The OM exposes runtime state via its JMX servlet at `GET /jmx?qry=`, * always returning `{ beans: [...] }`. In development Vite proxies `/jmx` to the * json-server mock (see `mock/server.cjs`). * - * Fetches are keyed and de-duplicated by query string (see {@link fetchJmxBeans}): - * several sections of a page may depend on the same MBean (e.g. the OM - * ServerRuntime bean feeds Instance Details, Roles and Metadata Volume), yet the - * query is only issued once. Sections also fetch lazily, so a query is never - * sent for a section that is not rendered — this keeps us from pulling the full - * multi-thousand-line JMX dump when only a few beans are needed. + * Requests are issued via TanStack Query (see {@link useJmxBean}); query-key + * de-duplication means several sections depending on the same MBean (e.g. the OM + * ServerRuntime bean feeds Instance Details, Roles and Metadata Volume) share a + * single request, and sections fetch lazily so a query is never sent for a + * section that is not rendered. */ -const client = axios.create({ baseURL: '' }); - export interface JmxResponse { beans: T[]; } -/** Issue a JMX query and return the matching MBeans (no caching). */ +/** Issue a JMX query and return the matching MBeans (empty array when none). */ export async function queryJmx(qry: string): Promise { - const { data } = await client.get>('/jmx', { params: { qry } }); + const data = await fetchJson>('/jmx', { params: { qry } }); return data?.beans ?? []; } - -/** In-flight / resolved query cache, keyed by the JMX query string. */ -const cache = new Map>(); - -/** - * Fetch MBeans for a query, sharing a single request across all callers that ask - * for the same query. Failed requests are evicted so they can be retried. - */ -export function fetchJmxBeans(qry: string): Promise { - let pending = cache.get(qry) as Promise | undefined; - if (!pending) { - pending = queryJmx(qry).catch((err) => { - cache.delete(qry); - throw err; - }); - cache.set(qry, pending as Promise); - } - return pending; -} - -/** Fetch a single MBean for a query (the first bean), or `undefined`. */ -export async function fetchJmxBean(qry: string): Promise { - const beans = await fetchJmxBeans(qry); - return beans[0]; -} - -/** Drop all cached queries so the next fetch re-hits the endpoint (refresh). */ -export function clearJmxCache(): void { - cache.clear(); -} diff --git a/ozone-ui/packages/om/src/api/overview.ts b/ozone-ui/packages/om/src/api/overview.ts index f244bfe1e5cc..1dc1c2b4260b 100644 --- a/ozone-ui/packages/om/src/api/overview.ts +++ b/ozone-ui/packages/om/src/api/overview.ts @@ -43,7 +43,13 @@ export const JMX_QUERY = { export interface OzoneManagerInfoBean { RpcPort: string; - RatisRoles: string; + /** + * OM Ratis peers, one row per node. Each row is a tuple + * `[hostName, nodeId, ratisPort, role, leaderReadiness]` (see + * `OMMXBean.getRatisRoles` / `OmUtils.format`). On error the bean returns a + * single-element row `[message]`. + */ + RatisRoles: string[][]; RatisLogDirectory: string; RocksDbDirectory: string; Version: string; @@ -120,36 +126,28 @@ export interface JvmParameter { /* -------------------------------- Parsers --------------------------------- */ /** - * Parse the OM `RatisRoles` string, e.g. - * `{ HostName: h1 | Node-Id: om1 | Ratis-Port : 9872 | Role: FOLLOWER } {...}`. + * Parse the OM `RatisRoles` bean — an array of + * `[hostName, nodeId, ratisPort, role, leaderReadiness]` tuples. Rows that don't + * carry at least the first four fields (e.g. the single-element error row the + * bean returns when there is no leader) are skipped. */ -export function parseRatisRoles(raw: string, currentNodeId?: string): RatisRole[] { - const groups = raw?.match(/\{[^}]*\}/g) ?? []; - return groups.map((group, index) => { - const fields: Record = {}; - group - .replace(/[{}]/g, '') - .split('|') - .forEach((part) => { - const sep = part.indexOf(':'); - if (sep === -1) { - return; - } - fields[part.slice(0, sep).trim()] = part.slice(sep + 1).trim(); - }); - const role = (fields.Role ?? '').toUpperCase(); - const nodeId = fields['Node-Id'] ?? ''; - return { - key: nodeId || String(index), - hostName: fields.HostName ?? '', - nodeId, - ratisPort: fields['Ratis-Port'] ?? '', - role, - // The leader has no "readiness"; followers are shown as synced with the leader. - readiness: role === 'LEADER' ? null : 'Synced', - isCurrent: !!currentNodeId && nodeId === currentNodeId, - }; - }); +export function parseRatisRoles(rows: string[][] | undefined, currentNodeId?: string): RatisRole[] { + return (rows ?? []) + .filter((row) => Array.isArray(row) && row.length >= 4) + .map((row, index) => { + const [hostName = '', nodeId = '', ratisPort = '', roleRaw = ''] = row; + const role = roleRaw.toUpperCase(); + return { + key: nodeId || String(index), + hostName, + nodeId, + ratisPort, + role, + // The leader has no "readiness"; followers are shown as synced with the leader. + readiness: role === 'LEADER' ? null : 'Synced', + isCurrent: !!currentNodeId && nodeId === currentNodeId, + }; + }); } const MEMORY_GC = /Xm[xsn]|Xss|gc|CMS|Heap|Memory/i; @@ -219,8 +217,23 @@ function formatHeap(xmx: string | undefined): string { } const size = Number(match[1]); const unit = (match[2] ?? 'B').toUpperCase(); - const megabytes = unit === 'G' ? size * 1024 : unit === 'K' ? Math.round(size / 1024) : size; - return `${megabytes.toLocaleString('en-US')} MB`; + // A bare -Xmx value (no suffix) is a byte count, so normalise everything to MB. + let megabytes: number; + switch (unit) { + case 'G': + megabytes = size * 1024; + break; + case 'M': + megabytes = size; + break; + case 'K': + megabytes = size / 1024; + break; + default: + megabytes = size / (1024 * 1024); + break; + } + return `${Math.round(megabytes).toLocaleString('en-US')} MB`; } function detectGarbageCollector(args: string[]): string { diff --git a/ozone-ui/packages/om/src/api/useJmx.ts b/ozone-ui/packages/om/src/api/useJmx.ts index 125670e28963..19270023335c 100644 --- a/ozone-ui/packages/om/src/api/useJmx.ts +++ b/ozone-ui/packages/om/src/api/useJmx.ts @@ -16,42 +16,52 @@ * limitations under the License. */ -import { useEffect, useState } from 'react'; -import { fetchJmxBean } from './jmx'; +import { useQuery } from '@tanstack/react-query'; +import { queryJmx } from './jmx'; export interface JmxBeanState { data?: T; - loading: boolean; - error?: Error; + isLoading: boolean; + isError: boolean; + error: Error | null; + /** The query succeeded but no MBean matched (`{ beans: [] }`). */ + isEmpty: boolean; } +export interface UseJmxBeanOptions { + /** + * Auto-refresh interval in milliseconds. Omit or pass `false` to disable + * polling (the default). This is the hook-level hook for a future + * auto-polling toggle. + */ + refetchInterval?: number | false; + /** Disable the query until a dependency is ready. Defaults to `true`. */ + enabled?: boolean; +} + +/** The shared cache key for a JMX query, so callers can invalidate by prefix. */ +export const JMX_QUERY_KEY = 'jmx'; + /** - * Fetch a single JMX MBean for a section. Requests are de-duplicated by query - * (see {@link fetchJmxBean}), so multiple sections depending on the same MBean - * share one network call. Pass a changing `refreshToken` (together with - * `clearJmxCache()`) to force a refetch. + * Fetch a single JMX MBean (the first bean) for a section via TanStack Query. + * Requests are de-duplicated by query key, so multiple sections depending on the + * same MBean share one network call. Refresh by invalidating the `['jmx']` key. */ -export function useJmxBean(qry: string, refreshToken = 0): JmxBeanState { - const [state, setState] = useState>({ loading: true }); +export function useJmxBean(qry: string, options: UseJmxBeanOptions = {}): JmxBeanState { + const { refetchInterval = false, enabled = true } = options; - useEffect(() => { - let active = true; - setState({ loading: true }); - fetchJmxBean(qry) - .then((data) => { - if (active) { - setState({ data, loading: false }); - } - }) - .catch((error: Error) => { - if (active) { - setState({ loading: false, error }); - } - }); - return () => { - active = false; - }; - }, [qry, refreshToken]); + const query = useQuery({ + queryKey: [JMX_QUERY_KEY, qry], + queryFn: () => queryJmx(qry), + refetchInterval, + enabled, + }); - return state; + return { + data: query.data?.[0], + isLoading: query.isLoading, + isError: query.isError, + error: (query.error as Error | null) ?? null, + isEmpty: query.isSuccess && (query.data?.length ?? 0) === 0, + }; } diff --git a/ozone-ui/packages/om/src/main.tsx b/ozone-ui/packages/om/src/main.tsx index fc232ad0a9f4..b4564c2414c1 100644 --- a/ozone-ui/packages/om/src/main.tsx +++ b/ozone-ui/packages/om/src/main.tsx @@ -18,7 +18,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; -import { ThemeProvider } from '@ozone-ui/shared'; +import { QueryProvider, ThemeProvider } from '@ozone-ui/shared'; import '@fontsource/roboto/400.css'; import '@fontsource/roboto/500.css'; import '@fontsource/roboto/700.css'; @@ -27,10 +27,12 @@ import './index.css'; createRoot(document.getElementById('root')!).render( - - - - - + + + + + + + ); diff --git a/ozone-ui/packages/om/src/pages/Overview/OverviewPage.tsx b/ozone-ui/packages/om/src/pages/Overview/OverviewPage.tsx index 3a823cfdd982..d9a0c1d14234 100644 --- a/ozone-ui/packages/om/src/pages/Overview/OverviewPage.tsx +++ b/ozone-ui/packages/om/src/pages/Overview/OverviewPage.tsx @@ -16,26 +16,27 @@ * limitations under the License. */ -import React, { useState } from 'react'; +import React from 'react'; import { Button } from 'antd'; +import { useQueryClient } from '@tanstack/react-query'; import { PageHeader, Icon } from '@ozone-ui/shared'; -import { clearJmxCache } from '../../api/jmx'; +import { JMX_QUERY_KEY } from '../../api/useJmx'; import InstanceDetailsSection from './sections/InstanceDetailsSection'; import RolesSection from './sections/RolesSection'; import MetadataVolumeSection from './sections/MetadataVolumeSection'; import JvmSection from './sections/JvmSection'; /** - * OM Overview page. Each section fetches its own JMX MBean lazily; sections that - * share a query (the OM ServerRuntime bean feeds three of them) are de-duplicated - * to a single request by the JMX cache. Refresh clears the cache and re-fetches. + * OM Overview page. Each section fetches its own JMX MBean lazily via TanStack + * Query; sections that share a query (the OM ServerRuntime bean feeds three of + * them) are de-duplicated to a single request by query key. Refresh invalidates + * the `['jmx']` cache so every visible query refetches. */ export const OverviewPage: React.FC = () => { - const [refreshToken, setRefreshToken] = useState(0); + const queryClient = useQueryClient(); const refresh = () => { - clearJmxCache(); - setRefreshToken((t) => t + 1); + queryClient.invalidateQueries({ queryKey: [JMX_QUERY_KEY] }); }; return ( @@ -48,10 +49,10 @@ export const OverviewPage: React.FC = () => { } /> - - - - + + + +
); }; diff --git a/ozone-ui/packages/om/src/pages/Overview/SectionBody.tsx b/ozone-ui/packages/om/src/pages/Overview/SectionBody.tsx index eb89589e51bd..76de78515c6e 100644 --- a/ozone-ui/packages/om/src/pages/Overview/SectionBody.tsx +++ b/ozone-ui/packages/om/src/pages/Overview/SectionBody.tsx @@ -17,12 +17,16 @@ */ import React from 'react'; -import { Skeleton } from 'antd'; +import { Empty, Skeleton } from 'antd'; import { Alert } from '@ozone-ui/shared'; export interface SectionBodyProps { loading: boolean; error?: Error; + /** Query succeeded but returned no data — renders an explicit empty state. */ + isEmpty?: boolean; + /** Message for the empty state. Defaults to "No JMX data available". */ + emptyMessage?: string; /** Number of skeleton rows to show while loading. Defaults to 2. */ skeletonRows?: number; children: React.ReactNode; @@ -30,11 +34,14 @@ export interface SectionBodyProps { /** * Renders a section's async state: a skeleton while loading, an error alert on - * failure, or the resolved content. + * failure, an explicit empty state when the query returned no data, or the + * resolved content. */ export const SectionBody: React.FC = ({ loading, error, + isEmpty = false, + emptyMessage = 'No JMX data available', skeletonRows = 2, children, }) => { @@ -44,6 +51,9 @@ export const SectionBody: React.FC = ({ if (loading) { return ; } + if (isEmpty) { + return ; + } return <>{children}; }; diff --git a/ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx b/ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx index 2e219ddf9999..cab113118cca 100644 --- a/ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx +++ b/ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx @@ -34,21 +34,18 @@ const kvGridStyle: React.CSSProperties = { gap: '16px 24px', }; -export interface SectionProps { - refreshToken?: number; -} - /** * "Instance Details" card. Sourced from the OM ServerRuntime bean (shared with * the Roles and Metadata Volume sections) plus this node's Ratis bean. */ -export const InstanceDetailsSection: React.FC = ({ refreshToken }) => { +export const InstanceDetailsSection: React.FC = () => { const { data: omInfo, - loading, + isLoading, error, - } = useJmxBean(JMX_QUERY.omInfo, refreshToken); - const { data: ratis } = useJmxBean(JMX_QUERY.ratisServer, refreshToken); + isEmpty, + } = useJmxBean(JMX_QUERY.omInfo); + const { data: ratis } = useJmxBean(JMX_QUERY.ratisServer); const currentHost = omInfo ? parseRatisRoles(omInfo.RatisRoles, ratis?.Id).find((r) => r.isCurrent)?.hostName @@ -57,7 +54,12 @@ export const InstanceDetailsSection: React.FC = ({ refreshToken }) return (
- + {omInfo && (
diff --git a/ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx b/ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx index 5057ccc6b55c..9dff74973cee 100644 --- a/ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx +++ b/ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx @@ -31,7 +31,6 @@ import { } from '../../../api/overview'; import { useJmxBean } from '../../../api/useJmx'; import SectionBody from '../SectionBody'; -import type { SectionProps } from './InstanceDetailsSection'; const highlightsGridStyle: React.CSSProperties = { display: 'grid', @@ -101,12 +100,8 @@ const columns: TableColumnsType = [ * filterable and paginated Parameters table. Sourced from the JVM runtime bean, * fetched lazily only when this section renders. */ -export const JvmSection: React.FC = ({ refreshToken }) => { - const { - data: runtime, - loading, - error, - } = useJmxBean(JMX_QUERY.runtime, refreshToken); +export const JvmSection: React.FC = () => { + const { data: runtime, isLoading, error, isEmpty } = useJmxBean(JMX_QUERY.runtime); const [search, setSearch] = useState(''); const [category, setCategory] = useState<'All' | JvmParameterCategory>('All'); @@ -170,7 +165,12 @@ export const JvmSection: React.FC = ({ refreshToken }) => { return (
- + {runtime && (
diff --git a/ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx b/ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx index 38931df9dcc4..1a68e9548262 100644 --- a/ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx +++ b/ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx @@ -21,7 +21,6 @@ import { Card, KeyValuePair, Section } from '@ozone-ui/shared'; import { JMX_QUERY, type OzoneManagerInfoBean } from '../../../api/overview'; import { useJmxBean } from '../../../api/useJmx'; import SectionBody from '../SectionBody'; -import type { SectionProps } from './InstanceDetailsSection'; const gridStyle: React.CSSProperties = { display: 'grid', @@ -30,17 +29,23 @@ const gridStyle: React.CSSProperties = { }; /** "Metadata Volume Information" card. Sourced from the OM ServerRuntime bean. */ -export const MetadataVolumeSection: React.FC = ({ refreshToken }) => { +export const MetadataVolumeSection: React.FC = () => { const { data: omInfo, - loading, + isLoading, error, - } = useJmxBean(JMX_QUERY.omInfo, refreshToken); + isEmpty, + } = useJmxBean(JMX_QUERY.omInfo); return (
- + {omInfo && (
diff --git a/ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx b/ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx index cf85bc091ee4..93f8025b5e09 100644 --- a/ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx +++ b/ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx @@ -18,7 +18,7 @@ import React from 'react'; import type { TableColumnsType } from 'antd'; -import { Chip, DataTable, KeyValuePair, Section, TextLink } from '@ozone-ui/shared'; +import { Alert, Chip, DataTable, KeyValuePair, Section } from '@ozone-ui/shared'; import { JMX_QUERY, formatElapsed, @@ -31,7 +31,6 @@ import { } from '../../../api/overview'; import { useJmxBean } from '../../../api/useJmx'; import SectionBody from '../SectionBody'; -import type { SectionProps } from './InstanceDetailsSection'; /** Grid for the per-host details revealed when a role row is expanded. */ const detailsGridStyle: React.CSSProperties = { @@ -47,9 +46,7 @@ const columns: TableColumnsType = [ dataIndex: 'hostName', key: 'hostName', render: (hostName: string, row) => ( - - {hostName} - + {hostName} ), }, { title: 'Node ID', dataIndex: 'nodeId', key: 'nodeId' }, @@ -80,22 +77,29 @@ const columns: TableColumnsType = [ ]; /** "Ozone Manager Roles" HA table. Sourced from the OM ServerRuntime bean. */ -export const RolesSection: React.FC = ({ refreshToken }) => { +export const RolesSection: React.FC = () => { const { data: omInfo, - loading, + isLoading, error, - } = useJmxBean(JMX_QUERY.omInfo, refreshToken); - const { data: ratis } = useJmxBean(JMX_QUERY.ratisServer, refreshToken); - const { data: electionCount } = useJmxBean( - JMX_QUERY.leaderElectionCount, - refreshToken - ); - const { data: electionElapsed } = useJmxBean( - JMX_QUERY.leaderElectionElapsed, - refreshToken + isEmpty, + } = useJmxBean(JMX_QUERY.omInfo); + const ratisQuery = useJmxBean(JMX_QUERY.ratisServer); + const electionCountQuery = useJmxBean(JMX_QUERY.leaderElectionCount); + const electionElapsedQuery = useJmxBean( + JMX_QUERY.leaderElectionElapsed ); + const ratis = ratisQuery.data; + const electionCount = electionCountQuery.data; + const electionElapsed = electionElapsedQuery.data; + + // The primary bean (omInfo) drives the section's load/error/empty state; the + // secondary beans only enrich the expanded row, so surface their failures as a + // non-blocking partial-data warning rather than failing the whole section. + const partialError = + ratisQuery.isError || electionCountQuery.isError || electionElapsedQuery.isError; + const roles = omInfo ? parseRatisRoles(omInfo.RatisRoles, ratis?.Id) : []; // These details (RPC port, group id, leader-election metrics) are exposed only @@ -120,17 +124,32 @@ export const RolesSection: React.FC = ({ refreshToken }) => { return (
- - - columns={columns} - dataSource={roles} - rowKey="key" - size="middle" - expandable={{ - expandedRowRender: renderHostDetails, - rowExpandable: (record) => record.isCurrent, - }} - /> + +
+ {partialError && ( + + )} + + columns={columns} + dataSource={roles} + rowKey="key" + size="middle" + expandable={{ + expandedRowRender: renderHostDetails, + rowExpandable: (record) => record.isCurrent, + }} + /> +
); diff --git a/ozone-ui/packages/om/vite.config.ts b/ozone-ui/packages/om/vite.config.ts index 63135304badf..9cbeeee9c7d5 100644 --- a/ozone-ui/packages/om/vite.config.ts +++ b/ozone-ui/packages/om/vite.config.ts @@ -56,8 +56,8 @@ export default defineConfig({ 'antd-vendor': ['antd', '@ant-design/icons'], // Router 'router-vendor': ['react-router-dom'], - // HTTP client - 'axios-vendor': ['axios'], + // Server-state + 'query-vendor': ['@tanstack/react-query'], // Other utilities 'utils-vendor': ['@fontsource/roboto', 'less'], }, @@ -66,7 +66,14 @@ export default defineConfig({ }, // Optimize dependencies to prevent outdated cache issues optimizeDeps: { - include: ['react', 'react-dom', 'antd', '@ant-design/icons', 'react-router-dom', 'axios'], + include: [ + 'react', + 'react-dom', + 'antd', + '@ant-design/icons', + 'react-router-dom', + '@tanstack/react-query', + ], force: false, // Set to true temporarily if you need to force re-optimization }, server: { @@ -74,9 +81,10 @@ export default defineConfig({ '/api': { target: 'http://localhost:9862', }, - // JMX endpoint — proxied to the json-server mock in dev (see mock/server.cjs). + // JMX endpoint — the json-server mock in dev (see mock/server.cjs), or a real + // OM via `OM_JMX_TARGET=http://: pnpm --filter @ozone-ui/ozone-om dev`. '/jmx': { - target: 'http://localhost:9878', + target: process.env.OM_JMX_TARGET ?? 'http://localhost:9878', changeOrigin: true, }, }, @@ -103,7 +111,7 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: 'src/__tests__/vitest.setup.ts', - include: ['src/__tests__/**/*.test.tsx'], + include: ['src/__tests__/**/*.test.{ts,tsx}'], reporters: ['verbose'], }, }); diff --git a/ozone-ui/packages/recon/package.json b/ozone-ui/packages/recon/package.json index 560bf596b095..debf6d93f847 100644 --- a/ozone-ui/packages/recon/package.json +++ b/ozone-ui/packages/recon/package.json @@ -14,7 +14,6 @@ "ag-charts-community": "^7.3.0", "ag-charts-react": "^7.3.0", "antd": "^5.24.3", - "axios": "^1.9.0", "classnames": "^2.3.2", "echarts": "^5.5.0", "filesize": "^6.4.0", diff --git a/ozone-ui/packages/recon/vite.config.ts b/ozone-ui/packages/recon/vite.config.ts index d0ed72b742fa..684bcb26ab03 100644 --- a/ozone-ui/packages/recon/vite.config.ts +++ b/ozone-ui/packages/recon/vite.config.ts @@ -56,8 +56,6 @@ export default defineConfig({ 'antd-vendor': ['antd', '@ant-design/icons'], // Router 'router-vendor': ['react-router-dom'], - // HTTP client - 'axios-vendor': ['axios'], // Other utilities 'utils-vendor': ['@fontsource/roboto', 'less'], }, @@ -66,7 +64,7 @@ export default defineConfig({ }, // Optimize dependencies to prevent outdated cache issues optimizeDeps: { - include: ['react', 'react-dom', 'antd', '@ant-design/icons', 'react-router-dom', 'axios'], + include: ['react', 'react-dom', 'antd', '@ant-design/icons', 'react-router-dom'], force: false, // Set to true temporarily if you need to force re-optimization }, server: { diff --git a/ozone-ui/packages/scm/package.json b/ozone-ui/packages/scm/package.json index d07ab0bbb4c6..d9511cf0aef2 100644 --- a/ozone-ui/packages/scm/package.json +++ b/ozone-ui/packages/scm/package.json @@ -14,7 +14,6 @@ "ag-charts-community": "^7.3.0", "ag-charts-react": "^7.3.0", "antd": "^5.24.3", - "axios": "^1.9.0", "classnames": "^2.3.2", "echarts": "^5.5.0", "filesize": "^6.4.0", diff --git a/ozone-ui/packages/scm/vite.config.ts b/ozone-ui/packages/scm/vite.config.ts index a1b7859a7d26..061c96309c30 100644 --- a/ozone-ui/packages/scm/vite.config.ts +++ b/ozone-ui/packages/scm/vite.config.ts @@ -56,8 +56,6 @@ export default defineConfig({ 'antd-vendor': ['antd', '@ant-design/icons'], // Router 'router-vendor': ['react-router-dom'], - // HTTP client - 'axios-vendor': ['axios'], // Other utilities 'utils-vendor': ['@fontsource/roboto', 'less'], }, @@ -66,7 +64,7 @@ export default defineConfig({ }, // Optimize dependencies to prevent outdated cache issues optimizeDeps: { - include: ['react', 'react-dom', 'antd', '@ant-design/icons', 'react-router-dom', 'axios'], + include: ['react', 'react-dom', 'antd', '@ant-design/icons', 'react-router-dom'], force: false, // Set to true temporarily if you need to force re-optimization }, server: { diff --git a/ozone-ui/packages/shared/package.json b/ozone-ui/packages/shared/package.json index 2d400d66f30f..c52cabc2bbcc 100644 --- a/ozone-ui/packages/shared/package.json +++ b/ozone-ui/packages/shared/package.json @@ -19,6 +19,7 @@ "build": "tsc" }, "peerDependencies": { + "@tanstack/react-query": "^5.62.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.3.0" @@ -29,6 +30,7 @@ "classnames": "^2.3.2" }, "devDependencies": { + "@tanstack/react-query": "^5.62.0", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", "react-router-dom": "^7.3.0", diff --git a/ozone-ui/packages/shared/src/data/QueryProvider.tsx b/ozone-ui/packages/shared/src/data/QueryProvider.tsx new file mode 100644 index 000000000000..435acdb0260b --- /dev/null +++ b/ozone-ui/packages/shared/src/data/QueryProvider.tsx @@ -0,0 +1,40 @@ +/** + * 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, { useState } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createQueryClient } from './queryClient'; + +export interface QueryProviderProps { + children: React.ReactNode; + /** Provide a pre-built client (e.g. in tests); otherwise one is created once. */ + client?: QueryClient; +} + +/** + * Provides a TanStack Query client to an application subtree. Every Ozone app + * should mount this once near its root (alongside `ThemeProvider`) so data + * hooks share one cache. The client is created lazily and kept stable across + * re-renders. + */ +export const QueryProvider: React.FC = ({ children, client }) => { + const [queryClient] = useState(() => client ?? createQueryClient()); + return {children}; +}; + +export default QueryProvider; diff --git a/ozone-ui/packages/shared/src/data/fetchJson.ts b/ozone-ui/packages/shared/src/data/fetchJson.ts new file mode 100644 index 000000000000..112bc05e010b --- /dev/null +++ b/ozone-ui/packages/shared/src/data/fetchJson.ts @@ -0,0 +1,84 @@ +/** + * 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. + */ + +/** Query-string parameters. `undefined`/`null` values are skipped. */ +export type QueryParams = Record; + +export interface FetchJsonOptions extends Omit { + /** Query-string parameters appended to `url`. */ + params?: QueryParams; + /** Request body; objects are JSON-encoded with a JSON content-type. */ + body?: BodyInit | Record | null; +} + +/** Error thrown for non-2xx responses, carrying the HTTP status. */ +export class HttpError extends Error { + constructor( + public readonly status: number, + public readonly url: string, + message?: string + ) { + super(message ?? `Request to ${url} failed with status ${status}`); + this.name = 'HttpError'; + } +} + +function withParams(url: string, params?: QueryParams): string { + if (!params) { + return url; + } + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + search.append(key, String(value)); + } + } + const qs = search.toString(); + return qs ? `${url}${url.includes('?') ? '&' : '?'}${qs}` : url; +} + +/** + * Minimal JSON fetch helper built on the native `fetch` API — the standard + * transport for the Ozone service UIs (no third-party HTTP client). Appends + * query parameters, JSON-encodes object bodies, throws {@link HttpError} on + * non-2xx responses, and parses the JSON response as `T`. + */ +export async function fetchJson(url: string, options: FetchJsonOptions = {}): Promise { + const { params, body, headers, ...rest } = options; + + const isJsonBody = + body != null && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof Blob); + + const response = await fetch(withParams(url, params), { + ...rest, + headers: { + Accept: 'application/json', + ...(isJsonBody ? { 'Content-Type': 'application/json' } : {}), + ...headers, + }, + body: isJsonBody ? JSON.stringify(body) : (body as BodyInit | null | undefined), + }); + + if (!response.ok) { + throw new HttpError(response.status, url, `${response.status} ${response.statusText}`); + } + + return (await response.json()) as T; +} + +export default fetchJson; diff --git a/ozone-ui/packages/shared/src/data/queryClient.ts b/ozone-ui/packages/shared/src/data/queryClient.ts new file mode 100644 index 000000000000..0612a23c2b20 --- /dev/null +++ b/ozone-ui/packages/shared/src/data/queryClient.ts @@ -0,0 +1,44 @@ +/** + * 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 { QueryClient, type QueryClientConfig } from '@tanstack/react-query'; + +/** + * Default query behaviour shared by every Ozone service UI. Tuned for + * monitoring dashboards: data is considered fresh briefly, window-focus + * refetches are off (they surprise operators watching a screen), and failed + * requests retry once. Per-query auto-polling is opt-in via each query's + * `refetchInterval`; this is the single place to later enable it globally. + */ +export const defaultQueryClientConfig: QueryClientConfig = { + defaultOptions: { + queries: { + staleTime: 15_000, + gcTime: 5 * 60_000, + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}; + +/** Create a `QueryClient` pre-configured with the Ozone UI defaults. */ +export function createQueryClient(config: QueryClientConfig = defaultQueryClientConfig): QueryClient { + return new QueryClient(config); +} + +export default createQueryClient; diff --git a/ozone-ui/packages/shared/src/index.ts b/ozone-ui/packages/shared/src/index.ts index 1d6963ce0663..4ad84cad1f8f 100644 --- a/ozone-ui/packages/shared/src/index.ts +++ b/ozone-ui/packages/shared/src/index.ts @@ -51,5 +51,12 @@ export type { IconButtonProps } from './components/IconButton/IconButton'; export { default as Icon } from './components/Icon/Icon'; export type { IconProps, IconName } from './components/Icon/Icon'; +// Data fetching (TanStack Query foundation) +export { fetchJson, HttpError } from './data/fetchJson'; +export type { FetchJsonOptions, QueryParams } from './data/fetchJson'; +export { createQueryClient, defaultQueryClientConfig } from './data/queryClient'; +export { QueryProvider } from './data/QueryProvider'; +export type { QueryProviderProps } from './data/QueryProvider'; + // Utils export * from './utils/menuUtils'; diff --git a/ozone-ui/pnpm-lock.yaml b/ozone-ui/pnpm-lock.yaml index e3027a092b2c..5f10f13799a1 100644 --- a/ozone-ui/pnpm-lock.yaml +++ b/ozone-ui/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: antd: specifier: ^5.24.3 version: 5.26.4(moment@2.30.1)(react-dom@18.3.1)(react@18.3.1) - axios: - specifier: ^1.9.0 - version: 1.10.0 less: specifier: ^4.2.2 version: 4.3.0 @@ -108,6 +105,9 @@ importers: '@ozone-ui/shared': specifier: workspace:* version: link:../shared + '@tanstack/react-query': + specifier: ^5.62.0 + version: 5.101.4(react@18.3.1) ag-charts-community: specifier: ^7.3.0 version: 7.3.0 @@ -117,9 +117,6 @@ importers: antd: specifier: ^5.24.3 version: 5.26.4(moment@2.30.1)(react-dom@18.3.1)(react@18.3.1) - axios: - specifier: ^1.9.0 - version: 1.10.0 classnames: specifier: ^2.3.2 version: 2.5.1 @@ -238,9 +235,6 @@ importers: antd: specifier: ^5.24.3 version: 5.26.4(moment@2.30.1)(react-dom@18.3.1)(react@18.3.1) - axios: - specifier: ^1.9.0 - version: 1.10.0 classnames: specifier: ^2.3.2 version: 2.5.1 @@ -359,9 +353,6 @@ importers: antd: specifier: ^5.24.3 version: 5.26.4(moment@2.30.1)(react-dom@18.3.1)(react@18.3.1) - axios: - specifier: ^1.9.0 - version: 1.10.0 classnames: specifier: ^2.3.2 version: 2.5.1 @@ -478,6 +469,9 @@ importers: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) devDependencies: + '@tanstack/react-query': + specifier: ^5.62.0 + version: 5.101.4(react@18.3.1) '@types/react': specifier: ^18.3.1 version: 18.3.23 @@ -1737,6 +1731,17 @@ packages: defer-to-connect: 1.1.3 dev: true + /@tanstack/query-core@5.101.4: + resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} + + /@tanstack/react-query@5.101.4(react@18.3.1): + resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==} + peerDependencies: + react: ^18 || ^19 + dependencies: + '@tanstack/query-core': 5.101.4 + react: 18.3.1 + /@testing-library/dom@10.4.0: resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -2376,6 +2381,7 @@ packages: /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + dev: true /available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} @@ -2397,16 +2403,6 @@ packages: engines: {node: '>=4'} dev: true - /axios@1.10.0: - resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==} - dependencies: - follow-redirects: 1.15.9 - form-data: 4.0.3 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - dev: false - /axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2553,6 +2549,7 @@ packages: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 + dev: true /call-bind@1.0.8: resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} @@ -2742,6 +2739,7 @@ packages: engines: {node: '>= 0.8'} dependencies: delayed-stream: 1.0.0 + dev: true /commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} @@ -3078,6 +3076,7 @@ packages: /delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dev: true /depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} @@ -3135,6 +3134,7 @@ packages: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 + dev: true /duplexer3@0.1.5: resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==} @@ -3285,10 +3285,12 @@ packages: /es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} + dev: true /es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + dev: true /es-iterator-helpers@1.2.1: resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} @@ -3317,6 +3319,7 @@ packages: engines: {node: '>= 0.4'} dependencies: es-errors: 1.3.0 + dev: true /es-set-tostringtag@2.1.0: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} @@ -3326,6 +3329,7 @@ packages: get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 hasown: 2.0.2 + dev: true /es-shim-unscopables@1.1.0: resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} @@ -3799,16 +3803,6 @@ packages: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} dev: true - /follow-redirects@1.15.9: - resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - dev: false - /for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -3838,6 +3832,7 @@ packages: es-set-tostringtag: 2.1.0 hasown: 2.0.2 mime-types: 2.1.35 + dev: true /forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} @@ -3859,6 +3854,7 @@ packages: /function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + dev: true /function.prototype.name@1.1.8: resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} @@ -3904,6 +3900,7 @@ packages: has-symbols: 1.1.0 hasown: 2.0.2 math-intrinsics: 1.1.0 + dev: true /get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} @@ -3911,6 +3908,7 @@ packages: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + dev: true /get-stream@3.0.0: resolution: {integrity: sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==} @@ -4001,6 +3999,7 @@ packages: /gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + dev: true /got@9.6.0: resolution: {integrity: sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==} @@ -4074,12 +4073,14 @@ packages: /has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + dev: true /has-tostringtag@1.0.2: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} dependencies: has-symbols: 1.1.0 + dev: true /has-yarn@2.1.0: resolution: {integrity: sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==} @@ -4091,6 +4092,7 @@ packages: engines: {node: '>= 0.4'} dependencies: function-bind: 1.1.2 + dev: true /headers-polyfill@3.2.5: resolution: {integrity: sha512-tUCGvt191vNSQgttSyJoibR+VO+I6+iCHIUdhzEMJKE+EAL8BwCN7fUOZlY4ofOelNHsK+gEjxB/B+9N3EWtdA==} @@ -4949,6 +4951,7 @@ packages: /math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + dev: true /media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} @@ -4988,6 +4991,7 @@ packages: /mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + dev: true /mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} @@ -4999,6 +5003,7 @@ packages: engines: {node: '>= 0.6'} dependencies: mime-db: 1.52.0 + dev: true /mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} @@ -5694,10 +5699,6 @@ packages: ipaddr.js: 1.9.1 dev: true - /proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - dev: false - /prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} requiresBuild: true From 734c39d4c8d9101c8c63a893425dd322f65b2dc8 Mon Sep 17 00:00:00 2001 From: Abhishek Pal Date: Mon, 3 Aug 2026 15:01:11 +0530 Subject: [PATCH 4/5] Switch the layout for OM --- ozone-ui/packages/om/mock/jmxData.cjs | 1 + ozone-ui/packages/om/src/App.tsx | 12 +- ozone-ui/packages/om/src/api/overview.ts | 1 + ozone-ui/packages/om/src/api/useJmx.ts | 34 +++- .../om/src/pages/Overview/OverviewPage.tsx | 23 ++- .../om/src/pages/Overview/SectionBody.tsx | 60 ------ .../sections/InstanceDetailsSection.tsx | 103 ++++++---- .../pages/Overview/sections/JvmSection.tsx | 178 +++++++++--------- .../sections/MetadataVolumeSection.tsx | 52 +++-- .../pages/Overview/sections/RolesSection.tsx | 107 ++--------- ozone-ui/packages/shared/icons/404.svg | 10 + ozone-ui/packages/shared/icons/500.svg | 13 ++ .../packages/shared/icons/network-error.svg | 19 ++ .../ErrorBoundary/ErrorBoundary.tsx | 75 ++++++++ .../ErrorBoundary/QueryErrorBoundary.tsx | 86 +++++++++ .../src/components/ErrorState/ErrorState.tsx | 145 ++++++++++++++ .../components/ErrorState/illustrations.tsx | 45 +++++ ozone-ui/packages/shared/src/index.ts | 14 ++ 18 files changed, 662 insertions(+), 316 deletions(-) delete mode 100644 ozone-ui/packages/om/src/pages/Overview/SectionBody.tsx create mode 100644 ozone-ui/packages/shared/icons/404.svg create mode 100644 ozone-ui/packages/shared/icons/500.svg create mode 100644 ozone-ui/packages/shared/icons/network-error.svg create mode 100644 ozone-ui/packages/shared/src/components/ErrorBoundary/ErrorBoundary.tsx create mode 100644 ozone-ui/packages/shared/src/components/ErrorBoundary/QueryErrorBoundary.tsx create mode 100644 ozone-ui/packages/shared/src/components/ErrorState/ErrorState.tsx create mode 100644 ozone-ui/packages/shared/src/components/ErrorState/illustrations.tsx diff --git a/ozone-ui/packages/om/mock/jmxData.cjs b/ozone-ui/packages/om/mock/jmxData.cjs index 40c14fa30efa..e33fa6fc7f60 100644 --- a/ozone-ui/packages/om/mock/jmxData.cjs +++ b/ozone-ui/packages/om/mock/jmxData.cjs @@ -27,6 +27,7 @@ const ozoneManagerInfo = { name: 'Hadoop:service=OzoneManager,name=OzoneManagerInfo,component=ServerRuntime', modelerType: 'org.apache.hadoop.ozone.om.OzoneManager', RpcPort: '9862', + Namespace: 'ozone1783424901', // Array of [hostName, nodeId, ratisPort, role, leaderReadiness] tuples, matching // OMMXBean.getRatisRoles() (List>) on a real OM. RatisRoles: [ diff --git a/ozone-ui/packages/om/src/App.tsx b/ozone-ui/packages/om/src/App.tsx index 0c0e61e26e25..194f26ddd5b0 100644 --- a/ozone-ui/packages/om/src/App.tsx +++ b/ozone-ui/packages/om/src/App.tsx @@ -16,13 +16,19 @@ * limitations under the License. */ -import { Routes, Route, Navigate } from 'react-router-dom'; +import { Routes, Route, useNavigate } from 'react-router-dom'; import { AppstoreOutlined } from '@ant-design/icons'; -import { AppLayout, Chip, IconButton, Sidebar, UtilityBar } from '@ozone-ui/shared'; +import { AppLayout, Chip, IconButton, NotFoundState, Sidebar, UtilityBar } from '@ozone-ui/shared'; import { navItems, SIDEBAR_WIDTH } from './navigation'; import OverviewPage from './pages/Overview/OverviewPage'; import Placeholder from './pages/Placeholder'; +/** 404 page for unknown routes; the action returns to the Overview. */ +const NotFoundRoute = () => { + const navigate = useNavigate(); + return navigate('/')} />; +}; + /** Product branding: the app name plus a chip showing the current host. */ const BrandTitle = () => { const host = window.location.hostname; @@ -63,7 +69,7 @@ function App() { } /> } /> } /> - } /> + } /> ); diff --git a/ozone-ui/packages/om/src/api/overview.ts b/ozone-ui/packages/om/src/api/overview.ts index 1dc1c2b4260b..ac027a5befac 100644 --- a/ozone-ui/packages/om/src/api/overview.ts +++ b/ozone-ui/packages/om/src/api/overview.ts @@ -43,6 +43,7 @@ export const JMX_QUERY = { export interface OzoneManagerInfoBean { RpcPort: string; + Namespace: string; /** * OM Ratis peers, one row per node. Each row is a tuple * `[hostName, nodeId, ratisPort, role, leaderReadiness]` (see diff --git a/ozone-ui/packages/om/src/api/useJmx.ts b/ozone-ui/packages/om/src/api/useJmx.ts index 19270023335c..b1c880870195 100644 --- a/ozone-ui/packages/om/src/api/useJmx.ts +++ b/ozone-ui/packages/om/src/api/useJmx.ts @@ -16,7 +16,7 @@ * limitations under the License. */ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { queryJmx } from './jmx'; export interface JmxBeanState { @@ -28,6 +28,13 @@ export interface JmxBeanState { isEmpty: boolean; } +export interface SuspenseJmxBeanState { + /** The first matching MBean, or `undefined` when the query returned none. */ + data?: T; + /** The query succeeded but no MBean matched (`{ beans: [] }`). */ + isEmpty: boolean; +} + export interface UseJmxBeanOptions { /** * Auto-refresh interval in milliseconds. Omit or pass `false` to disable @@ -42,6 +49,18 @@ export interface UseJmxBeanOptions { /** The shared cache key for a JMX query, so callers can invalidate by prefix. */ export const JMX_QUERY_KEY = 'jmx'; +/** + * TanStack Query options for a JMX query. Shared by the plain and suspense hooks + * (and usable with `useSuspenseQueries`) so every JMX read dedupes on the same + * `['jmx', qry]` key. + */ +export function jmxQueryOptions(qry: string) { + return { + queryKey: [JMX_QUERY_KEY, qry] as const, + queryFn: () => queryJmx(qry), + }; +} + /** * Fetch a single JMX MBean (the first bean) for a section via TanStack Query. * Requests are de-duplicated by query key, so multiple sections depending on the @@ -51,8 +70,7 @@ export function useJmxBean(qry: string, options: UseJmxBeanOptions = {}): Jmx const { refetchInterval = false, enabled = true } = options; const query = useQuery({ - queryKey: [JMX_QUERY_KEY, qry], - queryFn: () => queryJmx(qry), + ...jmxQueryOptions(qry), refetchInterval, enabled, }); @@ -65,3 +83,13 @@ export function useJmxBean(qry: string, options: UseJmxBeanOptions = {}): Jmx isEmpty: query.isSuccess && (query.data?.length ?? 0) === 0, }; } + +/** + * Suspense variant: suspends while loading and throws to the nearest error + * boundary on failure, so the caller renders assuming data is settled. Returns + * the first matching MBean (or `undefined` when the endpoint returned no beans). + */ +export function useSuspenseJmxBean(qry: string): SuspenseJmxBeanState { + const { data } = useSuspenseQuery(jmxQueryOptions(qry)); + return { data: data[0], isEmpty: data.length === 0 }; +} diff --git a/ozone-ui/packages/om/src/pages/Overview/OverviewPage.tsx b/ozone-ui/packages/om/src/pages/Overview/OverviewPage.tsx index d9a0c1d14234..39e271de2104 100644 --- a/ozone-ui/packages/om/src/pages/Overview/OverviewPage.tsx +++ b/ozone-ui/packages/om/src/pages/Overview/OverviewPage.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { Button } from 'antd'; import { useQueryClient } from '@tanstack/react-query'; -import { PageHeader, Icon } from '@ozone-ui/shared'; +import { PageHeader, Icon, QueryErrorBoundary } from '@ozone-ui/shared'; import { JMX_QUERY_KEY } from '../../api/useJmx'; import InstanceDetailsSection from './sections/InstanceDetailsSection'; import RolesSection from './sections/RolesSection'; @@ -28,9 +28,12 @@ import JvmSection from './sections/JvmSection'; /** * OM Overview page. Each section fetches its own JMX MBean lazily via TanStack - * Query; sections that share a query (the OM ServerRuntime bean feeds three of - * them) are de-duplicated to a single request by query key. Refresh invalidates - * the `['jmx']` cache so every visible query refetches. + * Query (with `useSuspenseQuery`); sections that share a query (the OM + * ServerRuntime bean feeds three of them) are de-duplicated to a single request + * by query key. Because every section reads from the one `/jmx` endpoint, a + * transport/server failure fails them all, so a single `QueryErrorBoundary` + * renders one page-level error state. Refresh invalidates the `['jmx']` cache so + * every visible query refetches. */ export const OverviewPage: React.FC = () => { const queryClient = useQueryClient(); @@ -49,10 +52,14 @@ export const OverviewPage: React.FC = () => { } /> - - - - + +
+ + + + +
+
); }; diff --git a/ozone-ui/packages/om/src/pages/Overview/SectionBody.tsx b/ozone-ui/packages/om/src/pages/Overview/SectionBody.tsx deleted file mode 100644 index 76de78515c6e..000000000000 --- a/ozone-ui/packages/om/src/pages/Overview/SectionBody.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * 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 from 'react'; -import { Empty, Skeleton } from 'antd'; -import { Alert } from '@ozone-ui/shared'; - -export interface SectionBodyProps { - loading: boolean; - error?: Error; - /** Query succeeded but returned no data — renders an explicit empty state. */ - isEmpty?: boolean; - /** Message for the empty state. Defaults to "No JMX data available". */ - emptyMessage?: string; - /** Number of skeleton rows to show while loading. Defaults to 2. */ - skeletonRows?: number; - children: React.ReactNode; -} - -/** - * Renders a section's async state: a skeleton while loading, an error alert on - * failure, an explicit empty state when the query returned no data, or the - * resolved content. - */ -export const SectionBody: React.FC = ({ - loading, - error, - isEmpty = false, - emptyMessage = 'No JMX data available', - skeletonRows = 2, - children, -}) => { - if (error) { - return ; - } - if (loading) { - return ; - } - if (isEmpty) { - return ; - } - return <>{children}; -}; - -export default SectionBody; diff --git a/ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx b/ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx index cab113118cca..5b1dae1dd92a 100644 --- a/ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx +++ b/ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx @@ -16,17 +16,21 @@ * limitations under the License. */ -import React from 'react'; +import React, { Suspense } from 'react'; +import { Divider, Empty, Skeleton } from 'antd'; +import { useSuspenseQueries } from '@tanstack/react-query'; import { Card, KeyValuePair, Section } from '@ozone-ui/shared'; import { JMX_QUERY, + formatElapsed, formatStarted, parseRatisRoles, + type LeaderElectionCountBean, + type LeaderElectionElapsedBean, type OzoneManagerInfoBean, type RatisServerBean, } from '../../../api/overview'; -import { useJmxBean } from '../../../api/useJmx'; -import SectionBody from '../SectionBody'; +import { jmxQueryOptions } from '../../../api/useJmx'; const kvGridStyle: React.CSSProperties = { display: 'grid', @@ -34,46 +38,67 @@ const kvGridStyle: React.CSSProperties = { gap: '16px 24px', }; -/** - * "Instance Details" card. Sourced from the OM ServerRuntime bean (shared with - * the Roles and Metadata Volume sections) plus this node's Ratis bean. - */ -export const InstanceDetailsSection: React.FC = () => { - const { - data: omInfo, - isLoading, - error, - isEmpty, - } = useJmxBean(JMX_QUERY.omInfo); - const { data: ratis } = useJmxBean(JMX_QUERY.ratisServer); +const InstanceDetailsContent: React.FC = () => { + // Fetch all four beans in parallel (avoids an intra-component suspense waterfall). + const [omInfoQ, ratisQ, countQ, elapsedQ] = useSuspenseQueries({ + queries: [ + jmxQueryOptions(JMX_QUERY.omInfo), + jmxQueryOptions(JMX_QUERY.ratisServer), + jmxQueryOptions(JMX_QUERY.leaderElectionCount), + jmxQueryOptions(JMX_QUERY.leaderElectionElapsed), + ], + }); + + const omInfo = omInfoQ.data[0]; + const ratis = ratisQ.data[0]; + + if (!omInfo) { + return ; + } - const currentHost = omInfo - ? parseRatisRoles(omInfo.RatisRoles, ratis?.Id).find((r) => r.isCurrent)?.hostName - : undefined; + const currentHost = parseRatisRoles(omInfo.RatisRoles, ratis?.Id).find( + (r) => r.isCurrent + )?.hostName; + + const count = countQ.data[0]?.Count; + const elapsed = elapsedQ.data[0]?.Value; + const electionCount = count != null && count !== -1 ? String(count) : '—'; + const electionElapsed = elapsed != null && elapsed !== -1 ? formatElapsed(elapsed) : '—'; return ( -
- - - {omInfo && ( -
- - - - - - -
- )} -
-
-
+
+
+ + {omInfo.Namespace && } + + + +
+ +
+ + + + +
+
); }; +/** + * "Instance Details" card. The top row identifies the instance (host, namespace, + * build); the bottom row (below a divider) shows this node's runtime details — + * RPC port, Ratis group and leader-election metrics — which are exposed only by + * the OM node serving the UI. + */ +export const InstanceDetailsSection: React.FC = () => ( +
+ + }> + + + +
+); + export default InstanceDetailsSection; diff --git a/ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx b/ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx index 9dff74973cee..fc58eb7ca53d 100644 --- a/ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx +++ b/ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx @@ -16,8 +16,16 @@ * limitations under the License. */ -import React, { useMemo, useState } from 'react'; -import { Button, Dropdown, message, type MenuProps, type TableColumnsType } from 'antd'; +import React, { Suspense, useMemo, useState } from 'react'; +import { + Button, + Dropdown, + Empty, + message, + Skeleton, + type MenuProps, + type TableColumnsType, +} from 'antd'; import { DownOutlined } from '@ant-design/icons'; import { Card, Chip, DataTable, Icon, KeyValuePair, Section, SearchInput } from '@ozone-ui/shared'; import { @@ -29,8 +37,7 @@ import { type JvmParameterCategory, type RuntimeBean, } from '../../../api/overview'; -import { useJmxBean } from '../../../api/useJmx'; -import SectionBody from '../SectionBody'; +import { useSuspenseJmxBean } from '../../../api/useJmx'; const highlightsGridStyle: React.CSSProperties = { display: 'grid', @@ -95,13 +102,8 @@ const columns: TableColumnsType = [ }, ]; -/** - * "Java Virtual Machine" section: a Highlights card plus the searchable, - * filterable and paginated Parameters table. Sourced from the JVM runtime bean, - * fetched lazily only when this section renders. - */ -export const JvmSection: React.FC = () => { - const { data: runtime, isLoading, error, isEmpty } = useJmxBean(JMX_QUERY.runtime); +const JvmContent: React.FC = () => { + const { data: runtime, isEmpty } = useSuspenseJmxBean(JMX_QUERY.runtime); const [search, setSearch] = useState(''); const [category, setCategory] = useState<'All' | JvmParameterCategory>('All'); @@ -163,83 +165,89 @@ export const JvmSection: React.FC = () => { ); }; + if (isEmpty || !runtime) { + return ; + } + return ( -
- - {runtime && ( -
- -
- {highlights.map((h) => ( - - ))} -
-
- - - title="Parameters" - columns={columns} - dataSource={rows} - rowKey="key" - size="middle" - paginated - defaultPageSize={10} - rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }} - onRow={(record) => ({ - onClick: () => - setSelectedRowKeys((keys) => - keys.includes(record.key) - ? keys.filter((k) => k !== record.key) - : [...keys, record.key] - ), - style: { cursor: 'pointer' }, - })} - filters={ - <> - setSearch(e.target.value)} - placeholder="Search..." - width={256} - /> - - - - {category === 'All' ? 'All' : category} - - - - - setShowModules((v) => !v)} - style={{ cursor: 'pointer' }} - > - Show JVM Modules - - - } - actions={ - - } +
+ +
+ {highlights.map((h) => ( + + ))} +
+
+ + + title="Parameters" + columns={columns} + dataSource={rows} + rowKey="key" + size="middle" + paginated + defaultPageSize={10} + rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }} + onRow={(record) => ({ + onClick: () => + setSelectedRowKeys((keys) => + keys.includes(record.key) + ? keys.filter((k) => k !== record.key) + : [...keys, record.key] + ), + style: { cursor: 'pointer' }, + })} + filters={ + <> + setSearch(e.target.value)} + placeholder="Search..." + width={256} /> -
- )} - -
+ + + + {category === 'All' ? 'All' : category} + + + + + setShowModules((v) => !v)} + style={{ cursor: 'pointer' }} + > + Show JVM Modules + + + } + actions={ + + } + /> +
); }; +/** + * "Java Virtual Machine" section: a Highlights card plus the searchable, + * filterable and paginated Parameters table. Sourced from the JVM runtime bean, + * fetched lazily only when this section renders. + */ +export const JvmSection: React.FC = () => ( +
+ }> + + +
+); + export default JvmSection; diff --git a/ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx b/ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx index 1a68e9548262..9e38fe150149 100644 --- a/ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx +++ b/ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx @@ -16,11 +16,11 @@ * limitations under the License. */ -import React from 'react'; +import React, { Suspense } from 'react'; +import { Empty, Skeleton } from 'antd'; import { Card, KeyValuePair, Section } from '@ozone-ui/shared'; import { JMX_QUERY, type OzoneManagerInfoBean } from '../../../api/overview'; -import { useJmxBean } from '../../../api/useJmx'; -import SectionBody from '../SectionBody'; +import { useSuspenseJmxBean } from '../../../api/useJmx'; const gridStyle: React.CSSProperties = { display: 'grid', @@ -28,34 +28,30 @@ const gridStyle: React.CSSProperties = { gap: '16px 24px', }; -/** "Metadata Volume Information" card. Sourced from the OM ServerRuntime bean. */ -export const MetadataVolumeSection: React.FC = () => { - const { - data: omInfo, - isLoading, - error, - isEmpty, - } = useJmxBean(JMX_QUERY.omInfo); +const MetadataVolumeContent: React.FC = () => { + const { data: omInfo, isEmpty } = useSuspenseJmxBean(JMX_QUERY.omInfo); + + if (isEmpty || !omInfo) { + return ; + } return ( -
- - - {omInfo && ( -
- - -
- )} -
-
-
+
+ + +
); }; +/** "Metadata Volume Information" card. Sourced from the OM ServerRuntime bean. */ +export const MetadataVolumeSection: React.FC = () => ( +
+ + }> + + + +
+); + export default MetadataVolumeSection; diff --git a/ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx b/ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx index 93f8025b5e09..f536c93bff2c 100644 --- a/ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx +++ b/ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx @@ -16,29 +16,17 @@ * limitations under the License. */ -import React from 'react'; -import type { TableColumnsType } from 'antd'; -import { Alert, Chip, DataTable, KeyValuePair, Section } from '@ozone-ui/shared'; +import React, { Suspense } from 'react'; +import { Skeleton, type TableColumnsType } from 'antd'; +import { Chip, DataTable, Section } from '@ozone-ui/shared'; import { JMX_QUERY, - formatElapsed, parseRatisRoles, - type LeaderElectionCountBean, - type LeaderElectionElapsedBean, type OzoneManagerInfoBean, type RatisRole, type RatisServerBean, } from '../../../api/overview'; -import { useJmxBean } from '../../../api/useJmx'; -import SectionBody from '../SectionBody'; - -/** Grid for the per-host details revealed when a role row is expanded. */ -const detailsGridStyle: React.CSSProperties = { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', - gap: '16px 24px', - padding: '4px 8px 8px', -}; +import { useSuspenseJmxBean } from '../../../api/useJmx'; const columns: TableColumnsType = [ { @@ -76,83 +64,22 @@ const columns: TableColumnsType = [ }, ]; -/** "Ozone Manager Roles" HA table. Sourced from the OM ServerRuntime bean. */ -export const RolesSection: React.FC = () => { - const { - data: omInfo, - isLoading, - error, - isEmpty, - } = useJmxBean(JMX_QUERY.omInfo); - const ratisQuery = useJmxBean(JMX_QUERY.ratisServer); - const electionCountQuery = useJmxBean(JMX_QUERY.leaderElectionCount); - const electionElapsedQuery = useJmxBean( - JMX_QUERY.leaderElectionElapsed - ); - - const ratis = ratisQuery.data; - const electionCount = electionCountQuery.data; - const electionElapsed = electionElapsedQuery.data; - - // The primary bean (omInfo) drives the section's load/error/empty state; the - // secondary beans only enrich the expanded row, so surface their failures as a - // non-blocking partial-data warning rather than failing the whole section. - const partialError = - ratisQuery.isError || electionCountQuery.isError || electionElapsedQuery.isError; +const RolesContent: React.FC = () => { + const { data: omInfo } = useSuspenseJmxBean(JMX_QUERY.omInfo); + const { data: ratis } = useSuspenseJmxBean(JMX_QUERY.ratisServer); const roles = omInfo ? parseRatisRoles(omInfo.RatisRoles, ratis?.Id) : []; - // These details (RPC port, group id, leader-election metrics) are exposed only - // by the OM node serving the UI — so only the current node's row is - // expandable. Election count / elapsed time are hidden when absent or -1, - // mirroring the legacy OM UI. - const count = electionCount?.Count; - const elapsed = electionElapsed?.Value; - const showCount = count != null && count !== -1; - const showElapsed = elapsed != null && elapsed !== -1; - - const renderHostDetails = () => ( -
- - - {showCount && } - {showElapsed && ( - - )} -
- ); - - return ( -
- -
- {partialError && ( - - )} - - columns={columns} - dataSource={roles} - rowKey="key" - size="middle" - expandable={{ - expandedRowRender: renderHostDetails, - rowExpandable: (record) => record.isCurrent, - }} - /> -
-
-
- ); + return columns={columns} dataSource={roles} rowKey="key" size="middle" />; }; +/** "Ozone Manager Roles" HA table. Sourced from the OM ServerRuntime bean. */ +export const RolesSection: React.FC = () => ( +
+ }> + + +
+); + export default RolesSection; diff --git a/ozone-ui/packages/shared/icons/404.svg b/ozone-ui/packages/shared/icons/404.svg new file mode 100644 index 000000000000..35dc2d45d8ec --- /dev/null +++ b/ozone-ui/packages/shared/icons/404.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ozone-ui/packages/shared/icons/500.svg b/ozone-ui/packages/shared/icons/500.svg new file mode 100644 index 000000000000..e76399dab5de --- /dev/null +++ b/ozone-ui/packages/shared/icons/500.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/ozone-ui/packages/shared/icons/network-error.svg b/ozone-ui/packages/shared/icons/network-error.svg new file mode 100644 index 000000000000..9d355b807ea0 --- /dev/null +++ b/ozone-ui/packages/shared/icons/network-error.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ozone-ui/packages/shared/src/components/ErrorBoundary/ErrorBoundary.tsx b/ozone-ui/packages/shared/src/components/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 000000000000..79b46a659254 --- /dev/null +++ b/ozone-ui/packages/shared/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,75 @@ +/** + * 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 from 'react'; + +export interface ErrorBoundaryProps { + /** Render the fallback UI from the caught error and a `reset` callback. */ + fallbackRender: (args: { error: Error; reset: () => void }) => React.ReactNode; + /** Called when the boundary resets (e.g. to also reset query caches). */ + onReset?: () => void; + /** + * When any value in this array changes while an error is shown, the boundary + * clears itself automatically (e.g. reset on route change). + */ + resetKeys?: unknown[]; + children: React.ReactNode; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +/** + * Minimal React error boundary (no external dependency). Catches render-time + * errors — including those thrown by `useSuspenseQuery` — and renders a fallback + * with a `reset` handler. Compose with `QueryErrorBoundary` to also reset the + * query cache on retry. + */ +export class ErrorBoundary extends React.Component { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + componentDidUpdate(prev: ErrorBoundaryProps) { + if (this.state.error && prev.resetKeys !== this.props.resetKeys) { + const changed = + (prev.resetKeys?.length ?? 0) !== (this.props.resetKeys?.length ?? 0) || + (this.props.resetKeys ?? []).some((key, i) => !Object.is(key, prev.resetKeys?.[i])); + if (changed) { + this.reset(); + } + } + } + + reset = () => { + this.props.onReset?.(); + this.setState({ error: null }); + }; + + render() { + if (this.state.error) { + return this.props.fallbackRender({ error: this.state.error, reset: this.reset }); + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/ozone-ui/packages/shared/src/components/ErrorBoundary/QueryErrorBoundary.tsx b/ozone-ui/packages/shared/src/components/ErrorBoundary/QueryErrorBoundary.tsx new file mode 100644 index 000000000000..c3458ed0587b --- /dev/null +++ b/ozone-ui/packages/shared/src/components/ErrorBoundary/QueryErrorBoundary.tsx @@ -0,0 +1,86 @@ +/** + * 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 from 'react'; +import { QueryErrorResetBoundary } from '@tanstack/react-query'; +import { HttpError } from '../../data/fetchJson'; +import { NetworkErrorState, ServerErrorState } from '../ErrorState/ErrorState'; +import { ErrorBoundary } from './ErrorBoundary'; + +/** Arguments passed to a custom {@link QueryErrorBoundary} fallback. */ +export interface QueryErrorFallbackProps { + error: Error; + /** Reset the failed queries and clear the boundary (wired to Retry). */ + retry: () => void; +} + +export interface QueryErrorBoundaryProps { + children: React.ReactNode; + /** Override the default (network vs. 500) error page. */ + fallback?: (props: QueryErrorFallbackProps) => React.ReactNode; + /** Reset the boundary when any of these values change (e.g. the route). */ + resetKeys?: unknown[]; +} + +/** + * The default fallback: a server-side failure (HTTP 5xx) shows the 500 state; + * anything else — a network/timeout failure (native `fetch` rejects with a + * `TypeError`), an aborted request, or an unclassified error — shows the network + * state. Both wire their action button to `retry`. + */ +function defaultFallback({ error, retry }: QueryErrorFallbackProps): React.ReactNode { + if (error instanceof HttpError && error.status >= 500) { + return ; + } + return ; +} + +/** + * Page-level error boundary for TanStack Query. Because the Ozone service UIs + * read every section from one endpoint, a failure fails all queries — so this + * renders a single error page for the whole subtree. Retrying resets the failed + * queries (`QueryErrorResetBoundary`) and clears the boundary, so the suspended + * children refetch. + */ +export const QueryErrorBoundary: React.FC = ({ + children, + fallback = defaultFallback, + resetKeys, +}) => ( + + {({ reset }) => ( + + fallback({ + error, + retry: () => { + reset(); + clearBoundary(); + }, + }) + } + > + {children} + + )} + +); + +export default QueryErrorBoundary; diff --git a/ozone-ui/packages/shared/src/components/ErrorState/ErrorState.tsx b/ozone-ui/packages/shared/src/components/ErrorState/ErrorState.tsx new file mode 100644 index 000000000000..0bad0dd77e92 --- /dev/null +++ b/ozone-ui/packages/shared/src/components/ErrorState/ErrorState.tsx @@ -0,0 +1,145 @@ +/** + * 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 from 'react'; +import { Button, Typography } from 'antd'; +import { semanticColors, spacing, textStyles } from '../../theme/tokens'; +import { NetworkErrorArt, NotFoundArt, ServerErrorArt } from './illustrations'; + +export interface ErrorStateProps { + /** Illustration rendered above the title (e.g. one of the bundled error arts). */ + illustration?: React.ReactNode; + /** Bold headline, e.g. "Network Error". */ + title: React.ReactNode; + /** Supporting explanation shown under the title. */ + description?: React.ReactNode; + /** Primary action label. Defaults to "Refresh". Pass `null` to hide the button. */ + actionLabel?: string | null; + /** Action handler. Defaults to reloading the page. */ + onAction?: () => void; + style?: React.CSSProperties; +} + +/** + * Full-page error / empty state: a centred illustration, headline, description and + * a primary action button. Used for the network / 404 / 500 screens and any other + * "nothing to show" state. Presets ({@link NetworkErrorState}, {@link NotFoundState}, + * {@link ServerErrorState}) fill in the art and copy from the design. + */ +export const ErrorState: React.FC = ({ + illustration, + title, + description, + actionLabel = 'Refresh', + onAction, + style, +}) => { + const handleAction = () => { + if (onAction) { + onAction(); + } else { + window.location.reload(); + } + }; + + return ( +
+ {illustration} +
+ + {title} + + {description && ( + + {description} + + )} +
+ {actionLabel && ( + + )} +
+ ); +}; + +/** "Network Error" state — no response received from the server. */ +export const NetworkErrorState: React.FC> = ( + props +) => ( + } + title="Network Error" + description="No response received from server while fetching data" + {...props} + /> +); + +/** "Error 404" state — the requested page/route does not exist. */ +export const NotFoundState: React.FC> = (props) => ( + } + title="Error 404" + description="The page is not available at the moment" + {...props} + /> +); + +/** "Error 500" state — the server hit an internal error. */ +export const ServerErrorState: React.FC> = ( + props +) => ( + } + title="Error 500" + description="It’s not you, it’s us. We’re having an internal server error" + {...props} + /> +); + +export default ErrorState; diff --git a/ozone-ui/packages/shared/src/components/ErrorState/illustrations.tsx b/ozone-ui/packages/shared/src/components/ErrorState/illustrations.tsx new file mode 100644 index 000000000000..90ddda137dc3 --- /dev/null +++ b/ozone-ui/packages/shared/src/components/ErrorState/illustrations.tsx @@ -0,0 +1,45 @@ +/** + * 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. + */ + +/** + * Error-state illustrations. The SVG art is bundled inline (from + * `packages/shared/icons/*.svg`) as trusted static markup so the shared package + * stays free of an SVG bundler in its `tsc`-only build. Each renders a 96x96 glyph. + */ +import React from 'react'; + +const wrapperStyle: React.CSSProperties = { display: 'inline-flex', lineHeight: 0 }; + +const NetworkErrorArt_SVG = ``; + +export const NetworkErrorArt: React.FC = () => ( + +); + +const NotFoundArt_SVG = ``; + +export const NotFoundArt: React.FC = () => ( + +); + +const ServerErrorArt_SVG = ``; + +export const ServerErrorArt: React.FC = () => ( + +); + diff --git a/ozone-ui/packages/shared/src/index.ts b/ozone-ui/packages/shared/src/index.ts index 4ad84cad1f8f..312693d0f4c0 100644 --- a/ozone-ui/packages/shared/src/index.ts +++ b/ozone-ui/packages/shared/src/index.ts @@ -44,6 +44,20 @@ export { default as Chip } from './components/Chip/Chip'; export type { ChipProps, ChipColor, ChipVariant, ChipSize } from './components/Chip/Chip'; export { default as Alert } from './components/Alert/Alert'; export type { AlertProps } from './components/Alert/Alert'; +export { + default as ErrorState, + NetworkErrorState, + NotFoundState, + ServerErrorState, +} from './components/ErrorState/ErrorState'; +export type { ErrorStateProps } from './components/ErrorState/ErrorState'; +export { ErrorBoundary } from './components/ErrorBoundary/ErrorBoundary'; +export type { ErrorBoundaryProps } from './components/ErrorBoundary/ErrorBoundary'; +export { QueryErrorBoundary } from './components/ErrorBoundary/QueryErrorBoundary'; +export type { + QueryErrorBoundaryProps, + QueryErrorFallbackProps, +} from './components/ErrorBoundary/QueryErrorBoundary'; export { default as TextLink } from './components/TextLink/TextLink'; export type { TextLinkProps } from './components/TextLink/TextLink'; export { default as IconButton } from './components/IconButton/IconButton'; From 8e872d33a159a65fa06209494fe24c68984c1830 Mon Sep 17 00:00:00 2001 From: Abhishek Pal Date: Wed, 5 Aug 2026 18:40:59 +0530 Subject: [PATCH 5/5] add auto polling --- ozone-ui/packages/om/src/App.tsx | 70 +++-- ozone-ui/packages/om/src/api/useJmx.ts | 7 +- .../om/src/pages/Overview/OverviewPage.tsx | 57 ++-- ozone-ui/packages/shared/icons/404.svg | 10 - ozone-ui/packages/shared/icons/500.svg | 13 - .../packages/shared/icons/network-error.svg | 19 -- ozone-ui/packages/shared/package.json | 3 +- .../src/components/ErrorState/ErrorState.tsx | 46 ++-- .../src/components/ErrorState/icons.tsx | 59 +++++ .../components/ErrorState/illustrations.tsx | 45 ---- .../src/components/PageHeader/PageHeader.tsx | 62 ++--- .../src/components/SyncChip/SyncChip.tsx | 249 ++++++++++++++++++ .../src/components/UtilityBar/UtilityBar.tsx | 137 ++++------ .../shared/src/data/SyncConfigContext.tsx | 70 +++++ .../packages/shared/src/data/fetchJson.ts | 5 +- .../packages/shared/src/data/queryClient.ts | 4 +- .../shared/src/data/useRefetchInterval.ts | 35 +++ ozone-ui/packages/shared/src/index.ts | 9 + 18 files changed, 613 insertions(+), 287 deletions(-) delete mode 100644 ozone-ui/packages/shared/icons/404.svg delete mode 100644 ozone-ui/packages/shared/icons/500.svg delete mode 100644 ozone-ui/packages/shared/icons/network-error.svg create mode 100644 ozone-ui/packages/shared/src/components/ErrorState/icons.tsx delete mode 100644 ozone-ui/packages/shared/src/components/ErrorState/illustrations.tsx create mode 100644 ozone-ui/packages/shared/src/components/SyncChip/SyncChip.tsx create mode 100644 ozone-ui/packages/shared/src/data/SyncConfigContext.tsx create mode 100644 ozone-ui/packages/shared/src/data/useRefetchInterval.ts diff --git a/ozone-ui/packages/om/src/App.tsx b/ozone-ui/packages/om/src/App.tsx index 194f26ddd5b0..b69256187f58 100644 --- a/ozone-ui/packages/om/src/App.tsx +++ b/ozone-ui/packages/om/src/App.tsx @@ -16,10 +16,19 @@ * limitations under the License. */ +import { useEffect, useState } from 'react'; import { Routes, Route, useNavigate } from 'react-router-dom'; -import { AppstoreOutlined } from '@ant-design/icons'; -import { AppLayout, Chip, IconButton, NotFoundState, Sidebar, UtilityBar } from '@ozone-ui/shared'; +import { useQueryClient } from '@tanstack/react-query'; +import { + AppLayout, + Chip, + NotFoundState, + Sidebar, + SyncConfigProvider, + UtilityBar, +} from '@ozone-ui/shared'; import { navItems, SIDEBAR_WIDTH } from './navigation'; +import { JMX_QUERY_KEY } from './api/useJmx'; import OverviewPage from './pages/Overview/OverviewPage'; import Placeholder from './pages/Placeholder'; @@ -44,22 +53,38 @@ const BrandTitle = () => { ); }; -const utilityBar = ( - } - label="App switcher" - tooltip={null} - /> - } - branding={} - /> -); +/** + * Inner app shell. Must be rendered inside `SyncConfigProvider` and + * `QueryProvider` so `useSyncConfig` / `useQueryClient` are available. + * Tracks the last-refreshed timestamp by subscribing to the query cache, so the + * SyncChip always shows the correct time without any manual `setInterval`. + */ +function AppShell() { + const queryClient = useQueryClient(); + const [lastRefreshedAt, setLastRefreshedAt] = useState(() => new Date()); + + // Update the timestamp whenever any JMX query settles successfully — TanStack + // fires this on both auto-refetch and manual invalidation. + useEffect(() => { + const cache = queryClient.getQueryCache(); + const unsubscribe = cache.subscribe((event) => { + if ( + event.type === 'updated' && + event.action.type === 'success' && + Array.isArray(event.query.queryKey) && + event.query.queryKey[0] === JMX_QUERY_KEY + ) { + setLastRefreshedAt(new Date()); + } + }); + return unsubscribe; + }, [queryClient]); -function App() { return ( - }> + } lastRefreshedAt={lastRefreshedAt} />} + sider={} + > } /> } /> @@ -75,4 +100,17 @@ function App() { ); } +/** + * Application root. Wraps the shell in `SyncConfigProvider` so the auto-refresh + * toggle is available app-wide. `QueryProvider` and `ThemeProvider` are mounted + * above this in `main.tsx`. + */ +function App() { + return ( + + + + ); +} + export default App; diff --git a/ozone-ui/packages/om/src/api/useJmx.ts b/ozone-ui/packages/om/src/api/useJmx.ts index b1c880870195..10ecc04e5c84 100644 --- a/ozone-ui/packages/om/src/api/useJmx.ts +++ b/ozone-ui/packages/om/src/api/useJmx.ts @@ -17,6 +17,7 @@ */ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { useRefetchInterval } from '@ozone-ui/shared'; import { queryJmx } from './jmx'; export interface JmxBeanState { @@ -88,8 +89,12 @@ export function useJmxBean(qry: string, options: UseJmxBeanOptions = {}): Jmx * Suspense variant: suspends while loading and throws to the nearest error * boundary on failure, so the caller renders assuming data is settled. Returns * the first matching MBean (or `undefined` when the endpoint returned no beans). + * + * Automatically picks up the auto-refresh interval from the nearest + * `SyncConfigProvider` — no manual `setInterval` needed. */ export function useSuspenseJmxBean(qry: string): SuspenseJmxBeanState { - const { data } = useSuspenseQuery(jmxQueryOptions(qry)); + const refetchInterval = useRefetchInterval(); + const { data } = useSuspenseQuery({ ...jmxQueryOptions(qry), refetchInterval }); return { data: data[0], isEmpty: data.length === 0 }; } diff --git a/ozone-ui/packages/om/src/pages/Overview/OverviewPage.tsx b/ozone-ui/packages/om/src/pages/Overview/OverviewPage.tsx index 39e271de2104..f945ee8d7b57 100644 --- a/ozone-ui/packages/om/src/pages/Overview/OverviewPage.tsx +++ b/ozone-ui/packages/om/src/pages/Overview/OverviewPage.tsx @@ -17,10 +17,7 @@ */ import React from 'react'; -import { Button } from 'antd'; -import { useQueryClient } from '@tanstack/react-query'; -import { PageHeader, Icon, QueryErrorBoundary } from '@ozone-ui/shared'; -import { JMX_QUERY_KEY } from '../../api/useJmx'; +import { PageHeader, QueryErrorBoundary } from '@ozone-ui/shared'; import InstanceDetailsSection from './sections/InstanceDetailsSection'; import RolesSection from './sections/RolesSection'; import MetadataVolumeSection from './sections/MetadataVolumeSection'; @@ -28,40 +25,24 @@ import JvmSection from './sections/JvmSection'; /** * OM Overview page. Each section fetches its own JMX MBean lazily via TanStack - * Query (with `useSuspenseQuery`); sections that share a query (the OM - * ServerRuntime bean feeds three of them) are de-duplicated to a single request - * by query key. Because every section reads from the one `/jmx` endpoint, a - * transport/server failure fails them all, so a single `QueryErrorBoundary` - * renders one page-level error state. Refresh invalidates the `['jmx']` cache so - * every visible query refetches. + * Query (with `useSuspenseQuery`); sections that share a query are de-duplicated + * to a single request by query key. A single `QueryErrorBoundary` wraps all + * sections so a `/jmx` transport or server failure shows one page-level error + * state rather than per-section alerts. Refresh is driven from the utility bar + * via the SyncChip. */ -export const OverviewPage: React.FC = () => { - const queryClient = useQueryClient(); - - const refresh = () => { - queryClient.invalidateQueries({ queryKey: [JMX_QUERY_KEY] }); - }; - - return ( -
- } onClick={refresh}> - Refresh - - } - /> - -
- - - - -
-
-
- ); -}; +export const OverviewPage: React.FC = () => ( +
+ + +
+ + + + +
+
+
+); export default OverviewPage; diff --git a/ozone-ui/packages/shared/icons/404.svg b/ozone-ui/packages/shared/icons/404.svg deleted file mode 100644 index 35dc2d45d8ec..000000000000 --- a/ozone-ui/packages/shared/icons/404.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/ozone-ui/packages/shared/icons/500.svg b/ozone-ui/packages/shared/icons/500.svg deleted file mode 100644 index e76399dab5de..000000000000 --- a/ozone-ui/packages/shared/icons/500.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/ozone-ui/packages/shared/icons/network-error.svg b/ozone-ui/packages/shared/icons/network-error.svg deleted file mode 100644 index 9d355b807ea0..000000000000 --- a/ozone-ui/packages/shared/icons/network-error.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/ozone-ui/packages/shared/package.json b/ozone-ui/packages/shared/package.json index c52cabc2bbcc..db45aaaf5782 100644 --- a/ozone-ui/packages/shared/package.json +++ b/ozone-ui/packages/shared/package.json @@ -16,7 +16,8 @@ "dist" ], "scripts": { - "build": "tsc" + "build": "tsc", + "lint": "eslint ." }, "peerDependencies": { "@tanstack/react-query": "^5.62.0", diff --git a/ozone-ui/packages/shared/src/components/ErrorState/ErrorState.tsx b/ozone-ui/packages/shared/src/components/ErrorState/ErrorState.tsx index 0bad0dd77e92..3ecb9e85d15e 100644 --- a/ozone-ui/packages/shared/src/components/ErrorState/ErrorState.tsx +++ b/ozone-ui/packages/shared/src/components/ErrorState/ErrorState.tsx @@ -19,11 +19,11 @@ import React from 'react'; import { Button, Typography } from 'antd'; import { semanticColors, spacing, textStyles } from '../../theme/tokens'; -import { NetworkErrorArt, NotFoundArt, ServerErrorArt } from './illustrations'; +import { NetworkErrorIcon, NotFoundIcon, ServerErrorIcon } from './icons'; export interface ErrorStateProps { - /** Illustration rendered above the title (e.g. one of the bundled error arts). */ - illustration?: React.ReactNode; + /** icon rendered above the title (e.g. one of the bundled error arts). */ + icon?: React.ReactNode; /** Bold headline, e.g. "Network Error". */ title: React.ReactNode; /** Supporting explanation shown under the title. */ @@ -36,13 +36,12 @@ export interface ErrorStateProps { } /** - * Full-page error / empty state: a centred illustration, headline, description and + * Full-page error / empty state: a centred icon, headline, description and * a primary action button. Used for the network / 404 / 500 screens and any other - * "nothing to show" state. Presets ({@link NetworkErrorState}, {@link NotFoundState}, - * {@link ServerErrorState}) fill in the art and copy from the design. + * "nothing to show" state. */ export const ErrorState: React.FC = ({ - illustration, + icon, title, description, actionLabel = 'Refresh', @@ -67,13 +66,22 @@ export const ErrorState: React.FC = ({ justifyContent: 'center', textAlign: 'center', gap: spacing.lg, - minHeight: 320, - padding: spacing.xxl, + paddingTop: '25%', + paddingInline: spacing.xxl, + paddingBottom: spacing.xxl, ...style, }} > - {illustration} -
+ {icon} +
= ({ }; /** "Network Error" state — no response received from the server. */ -export const NetworkErrorState: React.FC> = ( - props -) => ( +export const NetworkErrorState: React.FC> = (props) => ( } + icon={} title="Network Error" description="No response received from server while fetching data" {...props} @@ -121,9 +127,9 @@ export const NetworkErrorState: React.FC> = (props) => ( +export const NotFoundState: React.FC> = (props) => ( } + icon={} title="Error 404" description="The page is not available at the moment" {...props} @@ -131,11 +137,9 @@ export const NotFoundState: React.FC> = ( - props -) => ( +export const ServerErrorState: React.FC> = (props) => ( } + icon={} title="Error 500" description="It’s not you, it’s us. We’re having an internal server error" {...props} diff --git a/ozone-ui/packages/shared/src/components/ErrorState/icons.tsx b/ozone-ui/packages/shared/src/components/ErrorState/icons.tsx new file mode 100644 index 000000000000..be935fd3b318 --- /dev/null +++ b/ozone-ui/packages/shared/src/components/ErrorState/icons.tsx @@ -0,0 +1,59 @@ +/** + * 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. + */ + +/** + * Error-state icons. The SVG Icon is bundled inline (from + * `packages/shared/icons/*.svg`) as trusted static markup so the shared package + * stays free of an SVG bundler in its `tsc`-only build. Each renders a 96x96 glyph. + */ +import React from 'react'; + +const wrapperStyle: React.CSSProperties = { display: 'inline-flex', lineHeight: 0 }; + +const NetworkErrorIcon_SVG = ``; + +export const NetworkErrorIcon: React.FC = () => ( + +); + +const NotFoundIcon_SVG = ``; + +export const NotFoundIcon: React.FC = () => ( + +); + +const ServerErrorIcon_SVG = ``; + +export const ServerErrorIcon: React.FC = () => ( + +); diff --git a/ozone-ui/packages/shared/src/components/ErrorState/illustrations.tsx b/ozone-ui/packages/shared/src/components/ErrorState/illustrations.tsx deleted file mode 100644 index 90ddda137dc3..000000000000 --- a/ozone-ui/packages/shared/src/components/ErrorState/illustrations.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/** - * 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. - */ - -/** - * Error-state illustrations. The SVG art is bundled inline (from - * `packages/shared/icons/*.svg`) as trusted static markup so the shared package - * stays free of an SVG bundler in its `tsc`-only build. Each renders a 96x96 glyph. - */ -import React from 'react'; - -const wrapperStyle: React.CSSProperties = { display: 'inline-flex', lineHeight: 0 }; - -const NetworkErrorArt_SVG = ``; - -export const NetworkErrorArt: React.FC = () => ( - -); - -const NotFoundArt_SVG = ``; - -export const NotFoundArt: React.FC = () => ( - -); - -const ServerErrorArt_SVG = ``; - -export const ServerErrorArt: React.FC = () => ( - -); - diff --git a/ozone-ui/packages/shared/src/components/PageHeader/PageHeader.tsx b/ozone-ui/packages/shared/src/components/PageHeader/PageHeader.tsx index 7c54c4636713..a3caebac8e05 100644 --- a/ozone-ui/packages/shared/src/components/PageHeader/PageHeader.tsx +++ b/ozone-ui/packages/shared/src/components/PageHeader/PageHeader.tsx @@ -27,8 +27,6 @@ export interface PageHeaderProps { subtitle?: React.ReactNode; /** Optional content rendered above the title (e.g. breadcrumbs). */ breadcrumb?: React.ReactNode; - /** Right-aligned actions (buttons, filters, ...). */ - actions?: React.ReactNode; style?: React.CSSProperties; } @@ -37,13 +35,7 @@ export interface PageHeaderProps { * right-aligned actions, matching the "Page Header" component used at the top of * the Ozone content area. */ -export const PageHeader: React.FC = ({ - title, - subtitle, - breadcrumb, - actions, - style, -}) => { +export const PageHeader: React.FC = ({ title, subtitle, breadcrumb, style }) => { return (
= ({ }} > {breadcrumb} -
-
- + + {title} + + {subtitle && ( + - {title} - - {subtitle && ( - - {subtitle} - - )} -
- {actions && ( -
{actions}
+ {subtitle} + )}
diff --git a/ozone-ui/packages/shared/src/components/SyncChip/SyncChip.tsx b/ozone-ui/packages/shared/src/components/SyncChip/SyncChip.tsx new file mode 100644 index 000000000000..d0a35dc85af2 --- /dev/null +++ b/ozone-ui/packages/shared/src/components/SyncChip/SyncChip.tsx @@ -0,0 +1,249 @@ +/** + * 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, { useState } from 'react'; +import { Dropdown, Switch, Tooltip, Typography } from 'antd'; +import { colors, radius, semanticColors, spacing, textStyles } from '../../theme/tokens'; +import { useSyncConfig } from '../../data/SyncConfigContext'; +import { fetchJson } from '../../data/fetchJson'; +import Icon from '../Icon/Icon'; +import IconButton from '../IconButton/IconButton'; + +/** + * Configuration for the optional "Database Sync" row in the dropdown. This row + * is Recon-specific and must be omitted for OM, SCM and DN — the row is hidden + * when this prop is absent. + */ +export interface DbSyncConfig { + /** Row label, e.g. `"Database Sync"`. */ + label: string; + /** Status description, e.g. `"Delta update 1s ago, 3:01 PM"`. */ + description?: string; + /** Tooltip on the sync icon button. */ + tooltip?: string; + /** + * Endpoint to call when the user clicks the sync button. + * `SyncChip` issues a `POST` via `fetchJson` and manages the loading state. + */ + url: string; +} + +export interface SyncChipProps { + /** Timestamp of the last data refresh; shown as "Refreshed at …" under Auto Refresh. */ + lastRefreshedAt?: Date; + /** + * Optional Recon-specific "Database Sync" row. Omit for OM, SCM and DN. + */ + dbSync?: DbSyncConfig; +} + +function formatRefreshed(d: Date): string { + return d.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + hour12: true, + }); +} + +const dropdownRowStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + gap: spacing.xl, + padding: `${spacing.sm}px ${spacing.md}px`, +}; + +const rowLabelStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: spacing.xs, + fontSize: textStyles.bodyStandard.fontSize, + fontWeight: 600, + color: semanticColors.textPrimary, +}; + +const rowDescStyle: React.CSSProperties = { + fontSize: textStyles.bodySmall.fontSize, + color: semanticColors.textSecondary, + lineHeight: `${textStyles.bodySmall.lineHeight}px`, + marginTop: spacing.xxs, + maxWidth: 200, +}; + +/** + * Utility-bar chip showing the current auto-refresh state. Reads + * `enabled`/`setEnabled` from the nearest `SyncConfigProvider`. + * + * - **Live Sync** (auto-refresh on): green pill — bg `green[50]`, text `green[950]`. + * - **Manual Sync** (off): grey pill — bg `pewter[50]`, text `pewter[950]`. + */ +export const SyncChip: React.FC = ({ lastRefreshedAt, dbSync }) => { + const { enabled, setEnabled } = useSyncConfig(); + const [open, setOpen] = useState(false); + const [dbSyncing, setDbSyncing] = useState(false); + + const bgColor = enabled ? colors.green[50] : colors.pewter[50]; + const textColor = enabled ? colors.green[950] : colors.pewter[950]; + const dotColor = enabled ? colors.green[600] : colors.pewter[400]; + const chipLabel = enabled ? 'Live Sync' : 'Manual Sync'; + + const handleDbSync = async () => { + if (!dbSync || dbSyncing) { + return; + } + setDbSyncing(true); + try { + await fetchJson(dbSync.url, { method: 'POST' }); + } finally { + setDbSyncing(false); + setOpen(false); + } + }; + + const dropdownContent = ( +
+ {/* Auto Refresh row */} +
+
+
+ Auto Refresh + + + + + +
+ {lastRefreshedAt && ( + + Refreshed at {formatRefreshed(lastRefreshedAt)} + + )} +
+ +
+ + {/* Database Sync row — Recon only */} + {dbSync && ( + <> +
+
+
+
+ {dbSync.label} +
+ {dbSync.description && ( + {dbSync.description} + )} +
+ } + label={dbSync.label} + tooltip={dbSync.tooltip ?? `Trigger ${dbSync.label}`} + loading={dbSyncing} + onClick={handleDbSync} + /> +
+ + )} +
+ ); + + return ( + + + + ); +}; + +export default SyncChip; diff --git a/ozone-ui/packages/shared/src/components/UtilityBar/UtilityBar.tsx b/ozone-ui/packages/shared/src/components/UtilityBar/UtilityBar.tsx index 049647e6b83c..068e0b66fbf4 100644 --- a/ozone-ui/packages/shared/src/components/UtilityBar/UtilityBar.tsx +++ b/ozone-ui/packages/shared/src/components/UtilityBar/UtilityBar.tsx @@ -17,119 +17,96 @@ */ import React from 'react'; -import { BellOutlined, QuestionCircleOutlined, UserOutlined } from '@ant-design/icons'; +import { QuestionCircleOutlined } from '@ant-design/icons'; import { fontFamilies, semanticColors, spacing, textStyles } from '../../theme/tokens'; import IconButton from '../IconButton/IconButton'; +import SyncChip, { type DbSyncConfig } from '../SyncChip/SyncChip'; export interface UtilityBarProps { /** Left slot, e.g. an app switcher or menu button. */ leading?: React.ReactNode; /** Product branding shown next to the leading slot (name/logo + host chip). */ branding?: React.ReactNode; - /** @deprecated Use `branding`. Kept for back-compat; rendered when `branding` is unset. */ - title?: React.ReactNode; /** Optional centre slot (e.g. global search). */ center?: React.ReactNode; + /** Handler for the Help icon button. */ + onHelp?: () => void; + /** Timestamp of the last data refresh; forwarded to the embedded `SyncChip`. */ + lastRefreshedAt?: Date; /** - * Right slot. When omitted, the bar renders the standard Help / Notifications - * / Profile actions (wire them up via the `on*` handlers below). + * Recon-only: configuration for the "Database Sync" row in the `SyncChip` + * dropdown. Omit for OM, SCM and DN — the row is hidden when absent. */ - actions?: React.ReactNode; - /** Handler for the standard Help action (used when `actions` is not provided). */ - onHelp?: () => void; - /** Handler for the standard Notifications action. */ - onNotifications?: () => void; - /** Handler for the standard Profile action. */ - onProfile?: () => void; + dbSyncConfig?: DbSyncConfig; /** Height in px. Defaults to 48. */ height?: number; style?: React.CSSProperties; } /** - * Global top utility bar (the app chrome at the very top of every screen). - * Provides a leading slot, product `branding`, an optional centre slot and - * right-aligned actions — defaulting to the standard Help / Notifications / - * Profile buttons when `actions` is not supplied. + * Global top utility bar. Renders a leading slot, product branding, an optional + * centre slot, a Help button and the `SyncChip` auto-refresh control. Requires a + * `SyncConfigProvider` ancestor so the chip can read and toggle the refresh state. */ export const UtilityBar: React.FC = ({ leading, branding, - title, center, - actions, onHelp, - onNotifications, - onProfile, + lastRefreshedAt, + dbSyncConfig, height = 48, style, -}) => { - const brand = branding ?? title; - const rightContent = actions ?? ( - <> - } - label="Help" - onClick={onHelp} - /> - } - label="Notifications" - onClick={onNotifications} - /> - } - label="Profile" - onClick={onProfile} - /> - - ); +}) => ( +
+
+ {leading} + {branding && ( + + {branding} + + )} +
+ + {center &&
{center}
} - return (
-
- {leading} - {brand && ( - - {brand} - - )} -
- - {center &&
{center}
} - -
- {rightContent} -
+ } + label="Help" + onClick={onHelp} + /> +
- ); -}; +
+); export default UtilityBar; diff --git a/ozone-ui/packages/shared/src/data/SyncConfigContext.tsx b/ozone-ui/packages/shared/src/data/SyncConfigContext.tsx new file mode 100644 index 000000000000..227c86a6efd7 --- /dev/null +++ b/ozone-ui/packages/shared/src/data/SyncConfigContext.tsx @@ -0,0 +1,70 @@ +/** + * 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, { createContext, useContext, useState } from 'react'; + +/** Default polling interval: 30 seconds. */ +export const DEFAULT_REFRESH_INTERVAL_MS = 30_000; + +export interface SyncConfig { + /** Whether automatic polling is currently active. */ + enabled: boolean; + /** Polling interval when enabled (ms). Defaults to 30 000. */ + refetchIntervalMs: number; + /** Toggle auto-refresh on/off. */ + setEnabled: (enabled: boolean) => void; +} + +const SyncConfigContext = createContext({ + enabled: true, + refetchIntervalMs: DEFAULT_REFRESH_INTERVAL_MS, + setEnabled: () => undefined, +}); + +export interface SyncConfigProviderProps { + children: React.ReactNode; + /** Polling interval in ms. Defaults to {@link DEFAULT_REFRESH_INTERVAL_MS}. */ + refetchIntervalMs?: number; + /** Initial enabled state. Defaults to `true`. */ + defaultEnabled?: boolean; +} + +/** + * Provides the global auto-refresh toggle to all `SyncChip` instances and data + * hooks in the subtree. Every Ozone service UI mounts this once near the root + * (alongside `QueryProvider`). Data hooks read `useRefetchInterval()` to wire + * TanStack Query's native `refetchInterval` — no manual `setInterval` is needed. + */ +export const SyncConfigProvider: React.FC = ({ + children, + refetchIntervalMs = DEFAULT_REFRESH_INTERVAL_MS, + defaultEnabled = true, +}) => { + const [enabled, setEnabled] = useState(defaultEnabled); + + return ( + + {children} + + ); +}; + +/** Read the current sync configuration. Must be used inside `SyncConfigProvider`. */ +export function useSyncConfig(): SyncConfig { + return useContext(SyncConfigContext); +} diff --git a/ozone-ui/packages/shared/src/data/fetchJson.ts b/ozone-ui/packages/shared/src/data/fetchJson.ts index 112bc05e010b..d9aa23d9ae5a 100644 --- a/ozone-ui/packages/shared/src/data/fetchJson.ts +++ b/ozone-ui/packages/shared/src/data/fetchJson.ts @@ -62,7 +62,10 @@ export async function fetchJson(url: string, options: FetchJsonOptions = {}): const { params, body, headers, ...rest } = options; const isJsonBody = - body != null && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof Blob); + body != null && + typeof body === 'object' && + !(body instanceof FormData) && + !(body instanceof Blob); const response = await fetch(withParams(url, params), { ...rest, diff --git a/ozone-ui/packages/shared/src/data/queryClient.ts b/ozone-ui/packages/shared/src/data/queryClient.ts index 0612a23c2b20..5cdca9386b36 100644 --- a/ozone-ui/packages/shared/src/data/queryClient.ts +++ b/ozone-ui/packages/shared/src/data/queryClient.ts @@ -37,7 +37,9 @@ export const defaultQueryClientConfig: QueryClientConfig = { }; /** Create a `QueryClient` pre-configured with the Ozone UI defaults. */ -export function createQueryClient(config: QueryClientConfig = defaultQueryClientConfig): QueryClient { +export function createQueryClient( + config: QueryClientConfig = defaultQueryClientConfig +): QueryClient { return new QueryClient(config); } diff --git a/ozone-ui/packages/shared/src/data/useRefetchInterval.ts b/ozone-ui/packages/shared/src/data/useRefetchInterval.ts new file mode 100644 index 000000000000..735e7665bcf2 --- /dev/null +++ b/ozone-ui/packages/shared/src/data/useRefetchInterval.ts @@ -0,0 +1,35 @@ +/** + * 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 { useSyncConfig } from './SyncConfigContext'; + +/** + * Returns the `refetchInterval` value to pass to TanStack Query: the configured + * interval in milliseconds when auto-refresh is enabled, or `false` to disable + * polling. Reads from the nearest `SyncConfigProvider`. + * + * @example + * ```ts + * const refetchInterval = useRefetchInterval(); + * const { data } = useSuspenseQuery({ ...queryOptions, refetchInterval }); + * ``` + */ +export function useRefetchInterval(): number | false { + const { enabled, refetchIntervalMs } = useSyncConfig(); + return enabled ? refetchIntervalMs : false; +} diff --git a/ozone-ui/packages/shared/src/index.ts b/ozone-ui/packages/shared/src/index.ts index 312693d0f4c0..4980f6fc73b1 100644 --- a/ozone-ui/packages/shared/src/index.ts +++ b/ozone-ui/packages/shared/src/index.ts @@ -64,6 +64,8 @@ export { default as IconButton } from './components/IconButton/IconButton'; export type { IconButtonProps } from './components/IconButton/IconButton'; export { default as Icon } from './components/Icon/Icon'; export type { IconProps, IconName } from './components/Icon/Icon'; +export { default as SyncChip } from './components/SyncChip/SyncChip'; +export type { SyncChipProps, DbSyncConfig } from './components/SyncChip/SyncChip'; // Data fetching (TanStack Query foundation) export { fetchJson, HttpError } from './data/fetchJson'; @@ -71,6 +73,13 @@ export type { FetchJsonOptions, QueryParams } from './data/fetchJson'; export { createQueryClient, defaultQueryClientConfig } from './data/queryClient'; export { QueryProvider } from './data/QueryProvider'; export type { QueryProviderProps } from './data/QueryProvider'; +export { + SyncConfigProvider, + useSyncConfig, + DEFAULT_REFRESH_INTERVAL_MS, +} from './data/SyncConfigContext'; +export type { SyncConfig, SyncConfigProviderProps } from './data/SyncConfigContext'; +export { useRefetchInterval } from './data/useRefetchInterval'; // Utils export * from './utils/menuUtils';