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..8a92aeed4db8 --- /dev/null +++ b/ozone-ui/packages/om/mock/jmxData.cjs @@ -0,0 +1,168 @@ +/* + * 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..552988bcc771 --- /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). + */ + +/* eslint-disable @typescript-eslint/no-require-imports */ +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, () => { + 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..1de122729fde 100644 --- a/ozone-ui/packages/om/package.json +++ b/ozone-ui/packages/om/package.json @@ -2,8 +2,11 @@ "name": "@ozone-ui/ozone-om", "private": true, "version": "1.0.0", + "type": "module", "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..0c0e61e26e25 100644 --- a/ozone-ui/packages/om/src/App.tsx +++ b/ozone-ui/packages/om/src/App.tsx @@ -15,26 +15,57 @@ * 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..f244bfe1e5cc --- /dev/null +++ b/ozone-ui/packages/om/src/api/overview.ts @@ -0,0 +1,313 @@ +/** + * 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..125670e28963 --- /dev/null +++ b/ozone-ui/packages/om/src/api/useJmx.ts @@ -0,0 +1,57 @@ +/** + * 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..b4b976056f13 --- /dev/null +++ b/ozone-ui/packages/om/src/navigation.tsx @@ -0,0 +1,88 @@ +/** + * 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..2e219ddf9999 --- /dev/null +++ b/ozone-ui/packages/om/src/pages/Overview/sections/InstanceDetailsSection.tsx @@ -0,0 +1,77 @@ +/** + * 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..5057ccc6b55c --- /dev/null +++ b/ozone-ui/packages/om/src/pages/Overview/sections/JvmSection.tsx @@ -0,0 +1,245 @@ +/** + * 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..38931df9dcc4 --- /dev/null +++ b/ozone-ui/packages/om/src/pages/Overview/sections/MetadataVolumeSection.tsx @@ -0,0 +1,56 @@ +/** + * 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..cf85bc091ee4 --- /dev/null +++ b/ozone-ui/packages/om/src/pages/Overview/sections/RolesSection.tsx @@ -0,0 +1,139 @@ +/** + * 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..3d4f48c9acc6 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,59 +51,65 @@ export const AppLayout: React.FC = ({ maxContentWidth, }) => { return ( - - {sider} - - {(title || headerExtra) && ( -
+ {utilityBar} + + {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 new file mode 100644 index 000000000000..3e9d96e47779 --- /dev/null +++ b/ozone-ui/packages/shared/src/components/DataTable/DataTable.tsx @@ -0,0 +1,195 @@ +/** + * 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)} + 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 }} + > + + + ) : ( + + ); + + const mergedExpandable = expandable ? { expandIcon: themedExpandIcon, ...expandable } : undefined; + + const rows = useMemo(() => dataSource ?? [], [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..49d7b3681108 --- /dev/null +++ b/ozone-ui/packages/shared/src/components/DataTable/TablePagination.tsx @@ -0,0 +1,147 @@ +/** + * 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..bfd4faebe8c9 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,77 @@ const paths: Record = { strokeLinejoin="round" /> ), + grid: , + help: ( + + ), + info: ( + + ), + rpc: ( + + ), + server: ( + + ), + gauge: ( + + ), + stack: ( + + ), + logs: ( + + ), }; export interface IconProps extends Omit, 'name'> { @@ -220,7 +299,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..95a8d69a0357 --- /dev/null +++ b/ozone-ui/packages/shared/src/components/Section/Section.tsx @@ -0,0 +1,89 @@ +/** + * 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..049647e6b83c 100644 --- a/ozone-ui/packages/shared/src/components/UtilityBar/UtilityBar.tsx +++ b/ozone-ui/packages/shared/src/components/UtilityBar/UtilityBar.tsx @@ -17,77 +17,119 @@ */ 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, -}) => ( -
-
- {leading} - {title && ( - - {title} - - )} -
- - {center &&
{center}
} +}) => { + const brand = branding ?? title; + const rightContent = actions ?? ( + <> + } + label="Help" + onClick={onHelp} + /> + } + label="Notifications" + onClick={onNotifications} + /> + } + label="Profile" + onClick={onProfile} + /> + + ); + return (
- {actions} +
+ {leading} + {brand && ( + + {brand} + + )} +
+ + {center &&
{center}
} + +
+ {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);