You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ambari.apache.org by ji...@apache.org on 2026/08/18 03:53:58 UTC
(ambari) branch frontend-refactor updated: AMBARI-26633: Complete React NameNode HA and JournalNode management parity (#4175)
This is an automated email from the ASF dual-hosted git repository.
JiaLiangC pushed a commit to branch frontend-refactor
in repository https://gitbox.apache.org/repos/asf/ambari.git
The following commit(s) were added to refs/heads/frontend-refactor by this push:
new 8e305ac4e9 AMBARI-26633: Complete React NameNode HA and JournalNode management parity (#4175)
8e305ac4e9 is described below
commit 8e305ac4e9e7ab30dbaf2c9ca53115172437e645
Author: jialiang <ji...@gmail.com>
AuthorDate: Tue Aug 18 11:53:53 2026 +0800
AMBARI-26633: Complete React NameNode HA and JournalNode management parity (#4175)
* AMBARI-26633: Complete React NameNode HA and JournalNode management parity
---
.../Utility.operations.test.ts} | 26 +-
ambari-web/latest/src/Utils/Utility.ts | 6 +-
.../latest/src/Utils/credentialsUtils.test.ts | 79 ++
ambari-web/latest/src/Utils/credentialsUtils.ts | 79 +-
ambari-web/latest/src/Utils/taskUtils.test.ts | 195 +++++
ambari-web/latest/src/Utils/taskUtils.ts | 174 +++--
ambari-web/latest/src/api/credentialsApi.ts | 4 +-
.../latest/src/components/AssignMastersAddable.tsx | 8 +-
.../latest/src/components/InvalidKdcPopup.test.tsx | 64 ++
.../latest/src/components/InvalidKdcPopup.tsx | 79 +-
.../src/components/OperationsProgress.test.tsx | 149 ++++
.../latest/src/components/OperationsProgress.tsx | 518 ++++++++-----
.../src/components/StepWizard/WizardFooter.tsx | 4 +-
.../latest/src/components/StepWizard/index.tsx | 9 +-
ambari-web/latest/src/hooks/useConfigsTags.ts | 42 +-
.../latest/src/hooks/useKDCSessionState.test.tsx | 46 +-
ambari-web/latest/src/hooks/useKDCSessionState.tsx | 9 +
.../StepWizard.ts => hooks/useStepWizard.test.ts} | 41 +-
ambari-web/latest/src/hooks/useStepWizard.ts | 119 ++-
ambari-web/latest/src/router/RoutesList.tsx | 57 +-
ambari-web/latest/src/screens/Services/Actions.tsx | 39 +-
.../highAvailibility/haWorkflowUtils.test.ts | 383 +++++++++
.../Services/highAvailibility/haWorkflowUtils.ts | 505 ++++++++++++
.../highAvailibility/journalNode/Step1.tsx | 57 +-
.../highAvailibility/journalNode/Step2.tsx | 188 +++--
.../highAvailibility/journalNode/Step3.tsx | 230 +++---
.../highAvailibility/journalNode/Step4.tsx | 29 +-
.../highAvailibility/journalNode/Step5.tsx | 109 ++-
.../highAvailibility/journalNode/Step6.tsx | 102 ++-
.../highAvailibility/journalNode/Step7.test.tsx | 81 ++
.../highAvailibility/journalNode/Step7.tsx | 55 +-
.../journalNode/ValidateEnablement.tsx | 29 +-
.../journalNode/store/context.test.tsx | 120 +++
.../highAvailibility/journalNode/store/context.tsx | 223 ++++--
.../Services/highAvailibility/nameNode/Step1.tsx | 45 +-
.../Services/highAvailibility/nameNode/Step2.tsx | 28 +-
.../Services/highAvailibility/nameNode/Step3.tsx | 140 +++-
.../Services/highAvailibility/nameNode/Step4.tsx | 82 +-
.../Services/highAvailibility/nameNode/Step5.tsx | 167 ++--
.../Services/highAvailibility/nameNode/Step6.tsx | 138 ++--
.../highAvailibility/nameNode/Step7.test.tsx | 114 +++
.../Services/highAvailibility/nameNode/Step7.tsx | 257 ++++---
.../Services/highAvailibility/nameNode/Step8.tsx | 68 +-
.../highAvailibility/nameNode/Step9.test.tsx | 142 ++++
.../Services/highAvailibility/nameNode/Step9.tsx | 855 +++++++--------------
.../nameNode/ValidateEnablement.tsx | 10 +-
.../nameNode/store/context.test.tsx | 117 +++
.../highAvailibility/nameNode/store/context.tsx | 218 ++++--
.../highAvailibility/nameNode/wizardSteps.tsx | 10 +-
ambari-web/latest/src/types/StepWizard.ts | 13 +-
.../09-namenode-journalnode-ha-gap.md | 377 +++++++++
.../issues/09-namenode-journalnode-ha.md | 48 ++
52 files changed, 4822 insertions(+), 1865 deletions(-)
diff --git a/ambari-web/latest/src/types/StepWizard.ts b/ambari-web/latest/src/Utils/Utility.operations.test.ts
similarity index 60%
copy from ambari-web/latest/src/types/StepWizard.ts
copy to ambari-web/latest/src/Utils/Utility.operations.test.ts
index 95dcf46fde..0962ed31a9 100644
--- a/ambari-web/latest/src/types/StepWizard.ts
+++ b/ambari-web/latest/src/Utils/Utility.operations.test.ts
@@ -16,14 +16,20 @@
* limitations under the License.
*/
-import { ReactNode } from "react";
+import { describe, expect, it } from "vitest";
+import { isFailed, isFinished } from "./Utility";
-export interface Step {
- label: String;
- completed: Boolean;
- Component: ReactNode;
- canGoBack: Boolean;
- isNextEnabled: Boolean;
- onNext?:any;
- nextLabel?:String;
-}
+describe("operation terminal states", () => {
+ it.each(["FAILED", "TIMEDOUT", "ABORTED"])(
+ "treats %s as a retryable terminal failure",
+ (status) => {
+ expect(isFinished(status)).toBe(true);
+ expect(isFailed(status)).toBe(true);
+ },
+ );
+
+ it("treats COMPLETED as terminal but successful", () => {
+ expect(isFinished("COMPLETED")).toBe(true);
+ expect(isFailed("COMPLETED")).toBe(false);
+ });
+});
diff --git a/ambari-web/latest/src/Utils/Utility.ts b/ambari-web/latest/src/Utils/Utility.ts
index cded8ae6b2..816f05639c 100644
--- a/ambari-web/latest/src/Utils/Utility.ts
+++ b/ambari-web/latest/src/Utils/Utility.ts
@@ -278,11 +278,11 @@ export const isRequestRunning = (status: string) => {
};
export const isFinished = (status: string) => {
- return ["FAILED", "ABORTED", "COMPLETED"].includes(status);
+ return ["FAILED", "TIMEDOUT", "ABORTED", "COMPLETED"].includes(status);
};
export const isFailed = (status: string) => {
- return ["FAILED"].includes(status);
+ return ["FAILED", "TIMEDOUT", "ABORTED"].includes(status);
};
export const role = (role: string, isServiceRole: boolean) => {
if (isServiceRole) {
@@ -2097,4 +2097,4 @@ export const createFallbackWidgetsForService = (
}
return baseWidgets;
-};
\ No newline at end of file
+};
diff --git a/ambari-web/latest/src/Utils/credentialsUtils.test.ts b/ambari-web/latest/src/Utils/credentialsUtils.test.ts
new file mode 100644
index 0000000000..4be600d952
--- /dev/null
+++ b/ambari-web/latest/src/Utils/credentialsUtils.test.ts
@@ -0,0 +1,79 @@
+/**
+ * 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 { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ get: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+}));
+
+vi.mock("../api/credentialsApi", () => ({
+ default: {
+ getCredentials: mocks.get,
+ createCredentials: mocks.create,
+ updateCredentials: mocks.update,
+ },
+}));
+
+import credentialsUtils from "./credentialsUtils";
+
+describe("credentialsUtils.createOrUpdateCredentials", () => {
+ const resource = { principal: "admin", key: "secret", type: "temporary" };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.create.mockResolvedValue({});
+ mocks.update.mockResolvedValue({});
+ });
+
+ it("waits for an existing credential update", async () => {
+ mocks.get.mockResolvedValue({ Credential: { alias: "kdc" } });
+
+ await credentialsUtils.createOrUpdateCredentials("c1", "kdc", resource);
+
+ expect(mocks.update).toHaveBeenCalledOnce();
+ expect(mocks.create).not.toHaveBeenCalled();
+ expect(mocks.get.mock.invocationCallOrder[0]).toBeLessThan(
+ mocks.update.mock.invocationCallOrder[0],
+ );
+ });
+
+ it("creates only after an explicit missing-credential response", async () => {
+ mocks.get.mockRejectedValue({
+ response: { status: 404, data: { message: "NoSuchResourceException" } },
+ });
+
+ await credentialsUtils.createOrUpdateCredentials("c1", "kdc", resource);
+
+ expect(mocks.create).toHaveBeenCalledOnce();
+ expect(mocks.update).not.toHaveBeenCalled();
+ });
+
+ it("does not create or replay after a credential lookup failure", async () => {
+ const lookupError = new Error("credential API unavailable");
+ mocks.get.mockRejectedValue(lookupError);
+
+ await expect(
+ credentialsUtils.createOrUpdateCredentials("c1", "kdc", resource),
+ ).rejects.toBe(lookupError);
+ expect(mocks.create).not.toHaveBeenCalled();
+ expect(mocks.update).not.toHaveBeenCalled();
+ });
+});
diff --git a/ambari-web/latest/src/Utils/credentialsUtils.ts b/ambari-web/latest/src/Utils/credentialsUtils.ts
index a0c9b08770..8c24c3750f 100644
--- a/ambari-web/latest/src/Utils/credentialsUtils.ts
+++ b/ambari-web/latest/src/Utils/credentialsUtils.ts
@@ -19,6 +19,17 @@
import { find, map } from "lodash";
import CredentialsApi from "../api/credentialsApi";
+type CredentialResource = {
+ principal: string;
+ key: string;
+ type: string;
+};
+
+type StoredCredential = {
+ alias?: string;
+ type?: string;
+};
+
const credentialsUtils = {
STORE_TYPES: {
TEMPORARY: "temporary",
@@ -34,7 +45,7 @@ const credentialsUtils = {
createCredentials: async function (
clusterName: string,
alias: string,
- resource: string
+ resource: CredentialResource,
) {
return await CredentialsApi.createCredentials(clusterName, alias, {
resource,
@@ -43,8 +54,8 @@ const credentialsUtils = {
getCredential: async function (
clusterName: string,
alias: string,
- successCallback: any,
- errorCallback: any
+ successCallback: (data: unknown) => void,
+ errorCallback: () => void,
) {
try {
const data = await CredentialsApi.getCredentials(clusterName, alias);
@@ -58,7 +69,7 @@ const credentialsUtils = {
updateCredentials: async function (
clusterName: string,
alias: string,
- resource: string
+ resource: CredentialResource,
) {
return await CredentialsApi.updateCredentials(clusterName, alias, {
resource,
@@ -67,42 +78,48 @@ const credentialsUtils = {
createOrUpdateCredentials: async function (
clusterName: string,
alias: string,
- resource: string
+ resource: CredentialResource,
) {
- const self = this;
- self.getCredential(
- clusterName,
- alias,
- async () => {
- await self.updateCredentials(clusterName, alias, resource);
- var status = arguments[1];
- var result = arguments[2];
- if (status === "success") {
- return result;
- }
- },
- async () => {
- await self.createCredentials(clusterName, alias, resource);
- var status = arguments[1];
- var result = arguments[2];
- if (status === "success") {
- return result;
- }
+ try {
+ await CredentialsApi.getCredentials(clusterName, alias);
+ return await this.updateCredentials(clusterName, alias, resource);
+ } catch (error: unknown) {
+ const credentialError = error as {
+ message?: string;
+ status?: number;
+ response?: { status?: number; data?: { message?: string } };
+ };
+ const message = String(
+ credentialError.response?.data?.message || credentialError.message || "",
+ );
+ if (
+ credentialError.response?.status === 404 ||
+ credentialError.status === 404 ||
+ /NoSuchResourceException|not found/i.test(message)
+ ) {
+ return await this.createCredentials(clusterName, alias, resource);
}
- );
+ throw error;
+ }
},
- credentials: async function (clusterName: string, callback: Function) {
+ credentials: async function (
+ clusterName: string,
+ callback: (credentials: StoredCredential[]) => void,
+ ) {
const data = await CredentialsApi.listCredentials(clusterName);
callback(map(data?.items, "Credential"));
},
removeCredentials: async function (clusterName: string, alias: string) {
return await CredentialsApi.deleteCredentials(clusterName, alias);
},
- storageInfo: async function (clusterName: string, callback: Function) {
+ storageInfo: async function (
+ clusterName: string,
+ callback: (storage: Record<string, boolean> | null) => void,
+ ) {
const json = await CredentialsApi.credentialsStoreInfo(clusterName);
if (json.Clusters) {
const storage = json?.Clusters?.credential_store_properties ?? {};
- let storeTypesObject: any = {};
+ const storeTypesObject: Record<string, boolean> = {};
storeTypesObject[this.STORE_TYPES.PERSISTENT_KEY] =
storage[this.STORE_TYPES.PERSISTENT_PATH] === "true";
storeTypesObject[this.STORE_TYPES.TEMPORARY_KEY] =
@@ -119,7 +136,7 @@ const credentialsUtils = {
return this.storeTypeStatus(clusterName, this.STORE_TYPES.TEMPORARY_KEY);
},
storeTypeStatus: function (clusterName: string, type: string) {
- this.storageInfo(clusterName, function (storage: any) {
+ this.storageInfo(clusterName, function (storage) {
return storage?.[type];
});
},
@@ -134,8 +151,8 @@ const credentialsUtils = {
type: type,
};
},
- isKDCCredentialsPersisted: function (credentials: any) {
- var kdcCredentials = find(credentials, [
+ isKDCCredentialsPersisted: function (credentials: StoredCredential[]) {
+ const kdcCredentials = find(credentials, [
"alias",
this.ALIAS.KDC_CREDENTIALS,
]);
diff --git a/ambari-web/latest/src/Utils/taskUtils.test.ts b/ambari-web/latest/src/Utils/taskUtils.test.ts
new file mode 100644
index 0000000000..fdd29f52bf
--- /dev/null
+++ b/ambari-web/latest/src/Utils/taskUtils.test.ts
@@ -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 { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ getInstalled: vi.fn(),
+ createComponent: vi.fn(),
+ register: vi.fn(),
+ update: vi.fn(),
+ deleteHostComponent: vi.fn(),
+}));
+
+vi.mock("../api/hostsApi", () => ({
+ HostsApi: {
+ getInstalledHostsForHostComponents: mocks.getInstalled,
+ registerHostToComponent: mocks.register,
+ updateHostComponents: mocks.update,
+ deleteHostComponent: mocks.deleteHostComponent,
+ },
+}));
+vi.mock("../api/serviceApi", () => ({
+ ServiceApi: {
+ createComponent: mocks.createComponent,
+ },
+}));
+
+import { createInstallComponentTask, deleteComponent } from "./taskUtils";
+
+const serviceModel = {
+ masterComponents: [{ componentName: "JOURNALNODE" }],
+ clientComponents: [],
+ slaveComponents: [],
+};
+
+describe("HA component install chain", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.getInstalled.mockResolvedValue({ items: [] });
+ mocks.register.mockResolvedValue({ status: 201 });
+ mocks.update.mockResolvedValue({ status: 202, Requests: { id: 1 } });
+ mocks.deleteHostComponent.mockResolvedValue({ status: 200 });
+ });
+
+ it("registers missing host components before installing them", async () => {
+ await createInstallComponentTask(
+ "JOURNALNODE",
+ ["jn1", "jn2"],
+ "HDFS",
+ "c1",
+ ["HDFS"],
+ serviceModel,
+ );
+
+ expect(mocks.createComponent).not.toHaveBeenCalled();
+ expect(mocks.register).toHaveBeenCalledOnce();
+ expect(mocks.update).toHaveBeenCalledOnce();
+ expect(mocks.register.mock.invocationCallOrder[0]).toBeLessThan(
+ mocks.update.mock.invocationCallOrder[0],
+ );
+ });
+
+ it("stops when host-component registration fails", async () => {
+ mocks.register.mockRejectedValue(new Error("registration failed"));
+
+ await expect(
+ createInstallComponentTask(
+ "JOURNALNODE",
+ "jn1",
+ "HDFS",
+ "c1",
+ ["HDFS"],
+ serviceModel,
+ ),
+ ).rejects.toThrow("registration failed");
+ expect(mocks.update).not.toHaveBeenCalled();
+ });
+
+ it("stops when service-component creation fails", async () => {
+ mocks.createComponent.mockRejectedValue(new Error("create failed"));
+
+ await expect(
+ createInstallComponentTask(
+ "ZKFC",
+ "nn1",
+ "HDFS",
+ "c1",
+ ["HDFS"],
+ serviceModel,
+ ),
+ ).rejects.toThrow("create failed");
+ expect(mocks.register).not.toHaveBeenCalled();
+ expect(mocks.update).not.toHaveBeenCalled();
+ });
+
+ it("continues only for an explicit already-exists create response", async () => {
+ mocks.createComponent.mockRejectedValue(
+ new Error("ResourceAlreadyExists: ZKFC already exists"),
+ );
+
+ await createInstallComponentTask(
+ "ZKFC",
+ "nn1",
+ "HDFS",
+ "c1",
+ ["HDFS"],
+ serviceModel,
+ );
+
+ expect(mocks.register).toHaveBeenCalledOnce();
+ expect(mocks.update).toHaveBeenCalledOnce();
+ });
+
+ it("does not reinstall host components that are already present", async () => {
+ mocks.getInstalled.mockResolvedValue({
+ items: [
+ { HostRoles: { host_name: "jn1" } },
+ { HostRoles: { host_name: "jn2" } },
+ ],
+ });
+
+ await expect(
+ createInstallComponentTask(
+ "JOURNALNODE",
+ ["jn1", "jn2"],
+ "HDFS",
+ "c1",
+ ["HDFS"],
+ serviceModel,
+ ),
+ ).resolves.toEqual({ status: 200 });
+
+ expect(mocks.register).not.toHaveBeenCalled();
+ expect(mocks.update).not.toHaveBeenCalled();
+ });
+
+ it("registers and installs only missing host components", async () => {
+ mocks.getInstalled.mockResolvedValue({
+ items: [{ HostRoles: { host_name: "jn1" } }],
+ });
+
+ await createInstallComponentTask(
+ "JOURNALNODE",
+ ["jn1", "jn2"],
+ "HDFS",
+ "c1",
+ ["HDFS"],
+ serviceModel,
+ );
+
+ expect(mocks.register.mock.calls[0][1].RequestInfo.query).toBe(
+ "Hosts/host_name=jn2",
+ );
+ expect(mocks.update.mock.calls[0][2].query).toContain(
+ "HostRoles/host_name.in(jn2)",
+ );
+ });
+
+ it("treats a missing JournalNode as an idempotent delete", async () => {
+ mocks.update.mockRejectedValue({
+ response: { status: 404, data: { message: "NoSuchResourceException" } },
+ });
+
+ await expect(
+ deleteComponent("c1", "JOURNALNODE", "jn1", "HDFS", true),
+ ).resolves.toEqual({ status: 200 });
+ expect(mocks.deleteHostComponent).not.toHaveBeenCalled();
+ });
+
+ it("keeps missing-component errors fatal unless explicitly tolerated", async () => {
+ const missing = new Error("NoSuchResourceException");
+ mocks.update.mockRejectedValue(missing);
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+
+ await expect(
+ deleteComponent("c1", "SECONDARY_NAMENODE", "nn1", "HDFS"),
+ ).rejects.toBe(missing);
+ consoleError.mockRestore();
+ });
+});
diff --git a/ambari-web/latest/src/Utils/taskUtils.ts b/ambari-web/latest/src/Utils/taskUtils.ts
index 4a60827745..a42ddd1e40 100644
--- a/ambari-web/latest/src/Utils/taskUtils.ts
+++ b/ambari-web/latest/src/Utils/taskUtils.ts
@@ -16,7 +16,7 @@
* limitations under the License.
*/
-import { filter, find, isArray, map, set } from "lodash";
+import { filter, find, isArray, map } from "lodash";
import { HostsApi } from "../api/hostsApi";
import { ServiceApi } from "../api/serviceApi";
import { role } from "./Utility";
@@ -107,91 +107,81 @@ async function performCreateInstallComponentTask(
services: string[],
serviceObj: any
) {
- try {
- const hostNames = Array.isArray(hostName) ? hostName : [hostName];
- const data = await HostsApi.getInstalledHostsForHostComponents(
- clusterName,
- componentName,
- hostNames.join(",")
- );
- const hostsWithComponents: any = map(data.items, "HostRoles.host_name");
- const result = hostNames.map(function (item) {
- return {
- componentName: componentName,
- hostName: item,
- hasComponent: hostsWithComponents.includes(item),
- };
- });
- const hostsWithoutComponents = map(
- filter(result, ["hasComponent", false]),
- "hostName"
- );
- let taskNum = 1;
- const requestData = {
- RequestInfo: {
- query: hostsWithoutComponents
- .map(function (item) {
- return "Hosts/host_name=" + item;
- })
- .join("|"),
- },
- Body: {
- host_components: [
- {
- HostRoles: {
- component_name: componentName,
- },
- },
- ],
- },
+ void services;
+ const hostNames = Array.isArray(hostName) ? hostName : [hostName];
+ const data = await HostsApi.getInstalledHostsForHostComponents(
+ clusterName,
+ componentName,
+ hostNames.join(",")
+ );
+ const hostsWithComponents: any = map(data.items, "HostRoles.host_name");
+ const result = hostNames.map(function (item) {
+ return {
+ componentName: componentName,
+ hostName: item,
+ hasComponent: hostsWithComponents.includes(item),
};
- if (!!hostsWithoutComponents.length) {
- let allServiceComponents: any = [];
- //@ts-ignore
- let componentResponse = null;
- services.forEach(function (_service: any) {
- const _serviceComponents = [
- ...serviceObj.masterComponents,
- ...serviceObj.clientComponents,
- ...serviceObj.slaveComponents,
- ];
- allServiceComponents = allServiceComponents.concat(_serviceComponents);
- });
- if (map(allServiceComponents, "component_name").includes(componentName)) {
- return null;
- } else {
- componentResponse = await ServiceApi.createComponent(
- clusterName,
- serviceName,
- componentName
- );
- }
+ });
+ const hostsWithoutComponents = map(
+ filter(result, ["hasComponent", false]),
+ "hostName"
+ );
+ const taskNum = 1;
+ const requestData = {
+ RequestInfo: {
+ query: hostsWithoutComponents
+ .map(function (item) {
+ return "Hosts/host_name=" + item;
+ })
+ .join("|"),
+ },
+ Body: {
+ host_components: [
+ {
+ HostRoles: {
+ component_name: componentName,
+ },
+ },
+ ],
+ },
+ };
+ if (hostsWithoutComponents.length) {
+ const allServiceComponents = [
+ ...(serviceObj?.masterComponents || []),
+ ...(serviceObj?.clientComponents || []),
+ ...(serviceObj?.slaveComponents || []),
+ ];
+ const serviceComponentExists = allServiceComponents.some(
+ (component: any) =>
+ (component.component_name || component.componentName) === componentName,
+ );
+ if (!serviceComponentExists) {
try {
- await HostsApi.registerHostToComponent(clusterName, requestData);
- } catch (err) {
- } finally {
- return await updateComponent(
- clusterName,
- componentName,
- hostName,
- serviceName,
- "Install",
- taskNum
+ await ServiceApi.createComponent(clusterName, serviceName, componentName);
+ } catch (error: any) {
+ const message = String(
+ error?.response?.data?.message || error?.message || "",
);
+ if (!/already exists|resourcealreadyexists/i.test(message)) {
+ throw error;
+ }
}
- } else {
- return await updateComponent(
- clusterName,
- componentName,
- map(result, "hostName"),
- serviceName,
- "Install",
- taskNum
- );
}
- } catch (err) {
- console.log("Error in createInstallComponentTask", err);
+ await HostsApi.registerHostToComponent(clusterName, requestData);
}
+
+ if (!hostsWithoutComponents.length) {
+ return { status: 200 };
+ }
+
+ return await updateComponent(
+ clusterName,
+ componentName,
+ hostsWithoutComponents,
+ serviceName,
+ "Install",
+ taskNum
+ );
}
export async function startServices(
@@ -366,7 +356,8 @@ export async function deleteComponent(
clusterName: string,
componentName: string,
hostName: string,
- serviceName: string
+ serviceName: string,
+ ignoreMissing = false,
) {
try {
// First stop the component
@@ -380,14 +371,27 @@ export async function deleteComponent(
);
// Then delete the component from the host
- const response= await HostsApi.deleteHostComponent(
+ const response = await HostsApi.deleteHostComponent(
clusterName,
hostName,
componentName
);
- set(response,"data.status",response.status);
- return response.data;
- } catch (err) {
+ return response;
+ } catch (err: any) {
+ const message = String(
+ err?.response?.data?.message ||
+ err?.response?.data ||
+ err?.message ||
+ "",
+ );
+ if (
+ ignoreMissing &&
+ (err?.response?.status === 404 ||
+ err?.status === 404 ||
+ /NoSuchResourceException/i.test(message))
+ ) {
+ return { status: 200 };
+ }
console.error(`Error deleting component ${componentName} from host ${hostName}:`, err);
throw err;
}
diff --git a/ambari-web/latest/src/api/credentialsApi.ts b/ambari-web/latest/src/api/credentialsApi.ts
index 6db96bb46b..7a43506977 100644
--- a/ambari-web/latest/src/api/credentialsApi.ts
+++ b/ambari-web/latest/src/api/credentialsApi.ts
@@ -22,7 +22,7 @@ const CredentialsApi = {
createCredentials: async function (
clusterName: string,
alias: string,
- data: { resource: string }
+ data: { resource: unknown }
) {
const url = `/clusters/${clusterName}/credentials/${alias}`;
const response = await ambariApi.request({
@@ -59,7 +59,7 @@ const CredentialsApi = {
updateCredentials: async function (
clusterName: string,
alias: string,
- data:{resource:string}
+ data:{resource:unknown}
) {
const url = `/clusters/${clusterName}/credentials/${alias}`;
const response = await ambariApi.request({
diff --git a/ambari-web/latest/src/components/AssignMastersAddable.tsx b/ambari-web/latest/src/components/AssignMastersAddable.tsx
index 9b8a4fc082..51d693e5af 100644
--- a/ambari-web/latest/src/components/AssignMastersAddable.tsx
+++ b/ambari-web/latest/src/components/AssignMastersAddable.tsx
@@ -67,6 +67,7 @@ type AssignMastersAddableProps = {
showJournalNode?: boolean;
wizardName?: string;
servicesData?: any;
+ maximumMasterCount?: number;
};
function AssignMastersAddable({
@@ -84,6 +85,7 @@ function AssignMastersAddable({
showJournalNode = false,
wizardName = "",
servicesData = {},
+ maximumMasterCount,
}: AssignMastersAddableProps) {
const [hosts, setHosts] = useState([]);
const {
@@ -1042,7 +1044,11 @@ function AssignMastersAddable({
find(getFlattenedcomponents(), ["component_name", componentName])!
);
const hostsNumber = hosts.length;
- return Math.min(maxByCardinality, hostsNumber);
+ return Math.min(
+ maxByCardinality,
+ hostsNumber,
+ maximumMasterCount ?? Number.POSITIVE_INFINITY,
+ );
}
function updateComponent(componentName: string) {
diff --git a/ambari-web/latest/src/components/InvalidKdcPopup.test.tsx b/ambari-web/latest/src/components/InvalidKdcPopup.test.tsx
new file mode 100644
index 0000000000..f09569f023
--- /dev/null
+++ b/ambari-web/latest/src/components/InvalidKdcPopup.test.tsx
@@ -0,0 +1,64 @@
+/**
+ * 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 { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { AppContext } from "../store/context";
+
+const mocks = vi.hoisted(() => ({
+ hide: vi.fn(),
+ saveCredentials: vi.fn(),
+}));
+
+vi.mock("../store/ModalManager", () => ({
+ default: { show: vi.fn(), hide: mocks.hide },
+}));
+vi.mock("../Utils/credentialsUtils", () => ({
+ default: {
+ STORE_TYPES: { PERSISTENT: "persisted", TEMPORARY: "temporary" },
+ ALIAS: { KDC_CREDENTIALS: "kdc.admin.credential" },
+ createCredentialResource: vi.fn(() => ({})),
+ createOrUpdateCredentials: mocks.saveCredentials,
+ },
+}));
+
+import InvalidKdcPopup from "./InvalidKdcPopup";
+
+describe("InvalidKdcPopup", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ it("fails the protected operation when credential persistence fails", async () => {
+ const saveError = new Error("credential store unavailable");
+ mocks.saveCredentials.mockRejectedValue(saveError);
+ const onError = vi.fn();
+
+ render(
+ <AppContext.Provider value={{ clusterName: "c1" } as never}>
+ <InvalidKdcPopup getKdcSessionState={vi.fn()} onError={onError} />
+ </AppContext.Provider>,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "SAVE" }));
+
+ await waitFor(() => expect(onError).toHaveBeenCalledWith(saveError));
+ expect(mocks.hide).toHaveBeenCalledOnce();
+ });
+});
diff --git a/ambari-web/latest/src/components/InvalidKdcPopup.tsx b/ambari-web/latest/src/components/InvalidKdcPopup.tsx
index 50e1158971..a5c9d450a9 100644
--- a/ambari-web/latest/src/components/InvalidKdcPopup.tsx
+++ b/ambari-web/latest/src/components/InvalidKdcPopup.tsx
@@ -26,11 +26,25 @@ import modalManager from "../store/ModalManager";
import credentialsUtils from "../Utils/credentialsUtils";
import { AppContext } from "../store/context";
-function InvalidKdcPopup({getKdcSessionState}:any) {
+interface InvalidKdcPopupProps {
+ getKdcSessionState?: () => void;
+ onCancel?: (error: Error) => void;
+ onError?: (error: unknown) => void;
+}
+
+function InvalidKdcPopup({
+ getKdcSessionState,
+ onCancel,
+ onError,
+}: InvalidKdcPopupProps) {
const [principal, setPrincipal] = useState("");
const [password, setPassword] = useState("");
const [saveCreds, setSaveCreds] = useState(false);
- const { clusterName } = useContext(AppContext);
+ const [credentialError, setCredentialError] = useState("");
+ const { clusterName, cluster } = useContext(AppContext);
+ const canPersistCredentials =
+ cluster?.Clusters?.credential_store_properties?.["storage.persistent"] ===
+ "true";
if(!clusterName){
return null;
}
@@ -39,22 +53,43 @@ function InvalidKdcPopup({getKdcSessionState}:any) {
isOpen={true}
onClose={() => {
modalManager.hide();
+ onCancel?.(new Error("KDC credential entry was cancelled."));
}}
successCallback={async () => {
- modalManager.hide();
- const resource = credentialsUtils.createCredentialResource(
- principal,
- password,
- saveCreds
- ? credentialsUtils.STORE_TYPES.PERSISTENT
- : credentialsUtils.STORE_TYPES.TEMPORARY
- );
- await credentialsUtils.createOrUpdateCredentials(
- clusterName,
- credentialsUtils.ALIAS.KDC_CREDENTIALS,
- resource as any
- );
- setTimeout(getKdcSessionState, 1000);
+ setCredentialError("");
+ try {
+ const resource = credentialsUtils.createCredentialResource(
+ principal,
+ password,
+ saveCreds
+ ? credentialsUtils.STORE_TYPES.PERSISTENT
+ : credentialsUtils.STORE_TYPES.TEMPORARY
+ );
+ await credentialsUtils.createOrUpdateCredentials(
+ clusterName,
+ credentialsUtils.ALIAS.KDC_CREDENTIALS,
+ resource,
+ );
+ modalManager.hide();
+ if (getKdcSessionState) {
+ window.setTimeout(getKdcSessionState, 1000);
+ }
+ } catch (error: unknown) {
+ const credentialSaveError = error as {
+ message?: string;
+ response?: { data?: { message?: string } };
+ };
+ const message =
+ credentialSaveError.response?.data?.message ||
+ credentialSaveError.message ||
+ "Ambari could not save the KDC credentials.";
+ if (onError) {
+ modalManager.hide();
+ onError(error);
+ } else {
+ setCredentialError(message);
+ }
+ }
}}
modalTitle="Admin session expiration error"
modalBody={
@@ -64,6 +99,9 @@ function InvalidKdcPopup({getKdcSessionState}:any) {
Missing KDC administrator credentials. Please enter admin
principal and password.
</Alert>
+ {credentialError ? (
+ <Alert variant="danger">{credentialError}</Alert>
+ ) : null}
<Form.Label className="mt-2">Admin Principal</Form.Label>
<Form.Control
@@ -85,13 +123,20 @@ function InvalidKdcPopup({getKdcSessionState}:any) {
<Form.Check
id="save-creds"
checked={saveCreds}
+ disabled={!canPersistCredentials}
onChange={(e) => {
setSaveCreds(e.target.checked);
}}
></Form.Check>
<Form.Label className="ms-2 mt-1" htmlFor="save-creds">
Save Admin Credentials
- <Tooltip message="Ambari is not configured for storing credentials">
+ <Tooltip
+ message={
+ canPersistCredentials
+ ? "Store the KDC credential in Ambari's persistent credential store"
+ : "Ambari is not configured for storing credentials"
+ }
+ >
<FontAwesomeIcon
className="ms-1 custom-link cursor-pointer"
icon={faQuestionCircle}
diff --git a/ambari-web/latest/src/components/OperationsProgress.test.tsx b/ambari-web/latest/src/components/OperationsProgress.test.tsx
new file mode 100644
index 0000000000..dfd3856ce1
--- /dev/null
+++ b/ambari-web/latest/src/components/OperationsProgress.test.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 { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import type { ComponentProps, ContextType } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { AppContext } from "../store/context";
+
+const mocks = vi.hoisted(() => ({
+ getRequestStatus: vi.fn(),
+}));
+
+vi.mock("../api/requestApi", () => ({
+ RequestApi: { getRequestStatus: mocks.getRequestStatus },
+}));
+vi.mock("../store/ModalManager", () => ({
+ default: { show: vi.fn(), hide: vi.fn() },
+}));
+vi.mock("../screens/BackgroundOperations", () => ({
+ default: () => null,
+}));
+vi.mock("../Utils/statusIcons", () => ({
+ getStatusIcon: () => null,
+}));
+
+import OperationsProgress from "./OperationsProgress";
+
+type OperationsProgressProps = ComponentProps<typeof OperationsProgress>;
+
+const renderProgress = (
+ callback: OperationsProgressProps["operations"][number]["callback"],
+ dispatch: NonNullable<OperationsProgressProps["dispatch"]>,
+) =>
+ render(
+ <AppContext.Provider value={{ clusterName: "c1" } as ContextType<typeof AppContext>}>
+ <OperationsProgress
+ title=""
+ description=""
+ setCompletionStatus={vi.fn()}
+ operations={[
+ {
+ id: 1,
+ label: "Run task",
+ callback,
+ skippable: false,
+ },
+ ]}
+ dispatch={dispatch}
+ />
+ </AppContext.Provider>,
+ );
+
+describe("OperationsProgress persistence checkpoints", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ it("does not execute a queued task before its checkpoint is persisted", async () => {
+ let resolveCheckpoint: (() => void) | undefined;
+ const checkpoint = new Promise<void>((resolve) => {
+ resolveCheckpoint = resolve;
+ });
+ const dispatch = vi.fn().mockReturnValueOnce(checkpoint).mockResolvedValue(undefined);
+ const callback = vi.fn().mockResolvedValue({ status: 200 });
+
+ renderProgress(callback, dispatch);
+
+ await waitFor(() => expect(dispatch).toHaveBeenCalledOnce());
+ expect(dispatch.mock.calls[0][0][0].status).toBe("QUEUED");
+ expect(callback).not.toHaveBeenCalled();
+
+ await act(async () => resolveCheckpoint?.());
+ await waitFor(() => expect(callback).toHaveBeenCalledOnce());
+ });
+
+ it("retries checkpoint persistence before continuing the task", async () => {
+ const dispatch = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("checkpoint unavailable"))
+ .mockResolvedValue(undefined);
+ const callback = vi.fn().mockResolvedValue({ status: 200 });
+
+ renderProgress(callback, dispatch);
+
+ await screen.findByText("checkpoint unavailable");
+ expect(callback).not.toHaveBeenCalled();
+
+ fireEvent.click(screen.getByRole("button", { name: "Retry checkpoint" }));
+
+ await waitFor(() => expect(callback).toHaveBeenCalledOnce());
+ expect(dispatch).toHaveBeenCalledTimes(3);
+ });
+
+ it("does not resubmit a request when persisting its request ID fails", async () => {
+ const dispatch = vi
+ .fn()
+ .mockResolvedValueOnce(undefined)
+ .mockRejectedValueOnce(new Error("request checkpoint unavailable"))
+ .mockResolvedValue(undefined);
+ const callback = vi.fn().mockResolvedValue({
+ Requests: { id: 42, status: "Accepted" },
+ href: "/requests/42",
+ });
+ mocks.getRequestStatus.mockResolvedValue({
+ Requests: { request_status: "COMPLETED", progress_percent: 100 },
+ });
+
+ renderProgress(callback, dispatch);
+
+ await screen.findByText("request checkpoint unavailable");
+ expect(callback).toHaveBeenCalledOnce();
+ expect(mocks.getRequestStatus).not.toHaveBeenCalled();
+
+ fireEvent.click(screen.getByRole("button", { name: "Retry checkpoint" }));
+
+ await waitFor(() =>
+ expect(mocks.getRequestStatus).toHaveBeenCalledWith("c1", "42"),
+ );
+ expect(callback).toHaveBeenCalledOnce();
+ });
+
+ it("shows the original error for a terminal failed operation", async () => {
+ const dispatch = vi.fn().mockResolvedValue(undefined);
+ const callback = vi.fn().mockRejectedValue(new Error("server rejected task"));
+
+ renderProgress(callback, dispatch);
+
+ expect(await screen.findByText("server rejected task")).toBeTruthy();
+ expect(
+ screen.getByRole("button", { name: "Retry Operation" }),
+ ).toBeTruthy();
+ });
+});
diff --git a/ambari-web/latest/src/components/OperationsProgress.tsx b/ambari-web/latest/src/components/OperationsProgress.tsx
index 9fd783297e..472561659b 100644
--- a/ambari-web/latest/src/components/OperationsProgress.tsx
+++ b/ambari-web/latest/src/components/OperationsProgress.tsx
@@ -29,23 +29,24 @@ import modalManager from "../store/ModalManager";
import BackgroundOperations from "../screens/BackgroundOperations";
import { getStatusIcon } from "../Utils/statusIcons";
+type Operation = {
+ id: string | number;
+ label: string;
+ callback: any;
+ skippable: boolean;
+ requestId?: string | number;
+ status?: string;
+ progress?: number;
+ error?: string;
+ requestInfo?: any;
+};
+
type PropTypes = {
title: string;
description: string;
setCompletionStatus: (completed: boolean) => void;
- operations: [
- {
- id: string | number;
- label: "string";
- callback: any;
- skippable: boolean;
- requestId?: string | number;
- status?: string;
- progress?: number;
- error?: string;
- }
- ];
- dispatch?: (operationsState: any) => void;
+ operations: Operation[];
+ dispatch?: (operationsState: any) => void | Promise<void>;
errorCallback?: (errorMsg: string) => void;
};
@@ -65,24 +66,126 @@ function OperationsProgress({
errorCallback,
}: PropTypes) {
const [operationsState, setOperationsState] = useState(operations);
- const [activeOperationId, setActiveOperationId] = useState(-1);
+ const [activeOperationId, setActiveOperationId] = useState<
+ string | number | null
+ >(null);
+ const [persistenceError, setPersistenceError] = useState("");
+ const [isRetryingPersistence, setIsRetryingPersistence] = useState(false);
const { clusterName } = useContext(AppContext);
- const startedTasks: any = useRef([]);
+ const startedTasks = useRef<Set<string | number>>(new Set());
+ const operationsStateRef = useRef(operations);
+ const pollingTimers = useRef<Set<number>>(new Set());
+ const pendingPersistence = useRef<{
+ operations: Operation[];
+ continuation?: () => void | Promise<void>;
+ } | null>(null);
+ const isMounted = useRef(true);
+
+ const updateOperationsState = (nextOperations: typeof operations) => {
+ operationsStateRef.current = nextOperations;
+ if (isMounted.current) {
+ setOperationsState(nextOperations);
+ }
+ };
+
+ const persistenceMessage = (error: any, fallback: string) =>
+ error?.response?.data?.message || error?.message || fallback;
+
+ const persistAndContinue = async (
+ nextOperations: Operation[],
+ continuation?: () => void | Promise<void>,
+ fallbackMessage = "Ambari could not persist the operation checkpoint.",
+ ) => {
+ updateOperationsState(nextOperations);
+ try {
+ if (dispatch) {
+ await dispatch(nextOperations);
+ }
+ } catch (error: any) {
+ pendingPersistence.current = {
+ operations: nextOperations,
+ continuation,
+ };
+ if (isMounted.current) {
+ setPersistenceError(persistenceMessage(error, fallbackMessage));
+ }
+ return false;
+ }
+ pendingPersistence.current = null;
+ if (isMounted.current) {
+ setPersistenceError("");
+ }
+ await continuation?.();
+ return true;
+ };
+
+ const retryPersistence = async () => {
+ const pending = pendingPersistence.current;
+ if (!pending || isRetryingPersistence) return;
+ setIsRetryingPersistence(true);
+ try {
+ if (dispatch) {
+ await dispatch(pending.operations);
+ }
+ pendingPersistence.current = null;
+ if (isMounted.current) {
+ setPersistenceError("");
+ }
+ await pending.continuation?.();
+ } catch (error: any) {
+ if (isMounted.current) {
+ setPersistenceError(
+ persistenceMessage(
+ error,
+ "Ambari could not persist the operation checkpoint.",
+ ),
+ );
+ }
+ } finally {
+ if (isMounted.current) {
+ setIsRetryingPersistence(false);
+ }
+ }
+ };
+
+ const scheduleStatusPoll = (
+ requestId: string | number,
+ operationId: string | number,
+ ) => {
+ if (!isMounted.current) return;
+ const timer = window.setTimeout(() => {
+ pollingTimers.current.delete(timer);
+ void trackCurrentRequestStatus(requestId, operationId);
+ }, 2000);
+ pollingTimers.current.add(timer);
+ };
const trackCurrentRequestStatus = async (
- requestId: number,
- operationId: number
+ requestId: string | number,
+ operationId: string | number,
) => {
- const operationsStateCopy = cloneDeep(operationsState);
+ const operationsStateCopy = cloneDeep(operationsStateRef.current);
const trackingStatusForOperation: any = operationsStateCopy.find(
(operation) => operation.id == operationId
);
if (requestId) {
set(trackingStatusForOperation, "requestId", requestId);
- const requestStatus = await RequestApi.getRequestStatus(
- clusterName,
- String(requestId)
- );
+ let requestStatus;
+ try {
+ requestStatus = await RequestApi.getRequestStatus(
+ clusterName,
+ String(requestId)
+ );
+ } catch (error) {
+ set(
+ trackingStatusForOperation,
+ "error",
+ "Ambari could not read the request status. Polling will retry.",
+ );
+ updateOperationsState(operationsStateCopy);
+ scheduleStatusPoll(requestId, operationId);
+ return;
+ }
if (requestStatus?.Requests?.request_status) {
const { Requests } = requestStatus;
set(trackingStatusForOperation, "status", Requests?.request_status);
@@ -93,233 +196,215 @@ function OperationsProgress({
}
- setOperationsState(operationsStateCopy);
+ await persistAndContinue(operationsStateCopy, () => {
+ if (isFinished(Requests.request_status)) {
+ moveToNextOperation(operationsStateCopy, operationId);
+ } else {
+ scheduleStatusPoll(requestId, operationId);
+ }
+ });
+ } else {
+ set(
+ trackingStatusForOperation,
+ "error",
+ "Ambari returned an incomplete request status. Polling will retry.",
+ );
+ updateOperationsState(operationsStateCopy);
+ scheduleStatusPoll(requestId, operationId);
+ }
+ }
+ };
- if (!isFinished(Requests?.request_status)) {
- setTimeout(() => {
- trackCurrentRequestStatus(requestId, operationId);
- }, 2000);
+ const moveToNextOperation = (
+ currentOperations: Operation[],
+ operationId: string | number,
+ ) => {
+ const currentActiveOperation = currentOperations.find(
+ (operation) => operation.id == operationId,
+ );
+ if (
+ currentActiveOperation &&
+ isFinished(currentActiveOperation?.status || "")
+ ) {
+ if (!isFailed(currentActiveOperation?.status || "")) {
+ if (operationId == currentOperations.at(-1)?.id) {
+ setCompletionStatus(true);
+ } else {
+ const currentActiveIndex = currentOperations.findIndex(
+ (operation) => operation.id == operationId,
+ );
+ setActiveOperationId(currentOperations[currentActiveIndex + 1]?.id);
}
}
}
};
- async function executeTask() {
- if (!startedTasks.current.includes(activeOperationId)) {
- startedTasks.current.push(activeOperationId);
- const operationsStateCopy = cloneDeep(operationsState);
- const matchingOperation: any = operationsStateCopy.find(
- (operation) => operation.id == activeOperationId
+ const persistFailedOperation = async (
+ operationId: string | number,
+ error: any,
+ ) => {
+ const operationsStateCopy = cloneDeep(operationsStateRef.current);
+ const matchingOperation: any = operationsStateCopy.find(
+ (operation) => operation.id == operationId,
+ );
+ if (!matchingOperation) return;
+ const statusCode = get(error, "status", get(error, "response.status", ""));
+ const errorMessage =
+ get(error, "response.data.message", "") ||
+ error?.message ||
+ "The operation failed.";
+ set(matchingOperation, "status", ProgressStatus.FAILED);
+ set(
+ matchingOperation,
+ "error",
+ statusCode ? `Error ${statusCode}: ${errorMessage}` : errorMessage,
+ );
+ await persistAndContinue(operationsStateCopy);
+ };
+
+ const runOperationCallback = async (operationId: string | number) => {
+ const matchingOperation = operationsStateRef.current.find(
+ (operation) => operation.id == operationId,
+ );
+ if (!matchingOperation) return;
+ try {
+ const response: OperationRequestResponse =
+ await matchingOperation.callback();
+ const operationsStateCopy = cloneDeep(operationsStateRef.current);
+ const updatedOperation: any = operationsStateCopy.find(
+ (operation) => operation.id == operationId,
);
+ if (!updatedOperation) return;
- if (
- matchingOperation &&
- matchingOperation?.status === ProgressStatus.IN_PROGRESS
- ) {
- trackCurrentRequestStatus(
- matchingOperation.requestId,
- activeOperationId
+ if (response?.Requests) {
+ set(updatedOperation, "requestId", response.Requests.id);
+ set(updatedOperation, "status", ProgressStatus.IN_PROGRESS);
+ await persistAndContinue(
+ operationsStateCopy,
+ () => trackCurrentRequestStatus(response.Requests.id, operationId),
+ "Ambari could not persist the Ambari request ID.",
);
return;
}
- if (matchingOperation) {
- try {
- const operationCallbackResponse: OperationRequestResponse =
- await matchingOperation?.callback();
-
- if (operationCallbackResponse?.Requests) {
- set(
- matchingOperation,
- "requestId",
- operationCallbackResponse?.Requests?.id
- );
- trackCurrentRequestStatus(
- matchingOperation.requestId,
- activeOperationId
- );
- } else {
- const statusCode = get(operationCallbackResponse, "[0].status", operationCallbackResponse?.status);
-
- // Handle status codes by ranges in case of success or unknown response
- if (
- (statusCode && statusCode >= 200 && statusCode < 300) ||
- !operationCallbackResponse
- ) {
- // 2xx Success status codes or empty response with no status code - treat as success
- set(matchingOperation, "status", ProgressStatus.COMPLETED);
- if (has(matchingOperation, "error")) {
- delete matchingOperation.error;
- }
- } else {
- // Unknown status code or response exists but no status code
- set(matchingOperation, "status", ProgressStatus.FAILED);
- if (statusCode) {
- set(
- matchingOperation,
- "error",
- JSON.stringify(operationCallbackResponse) ||
- `Unknown Status Code (${statusCode}): ${JSON.stringify(
- operationCallbackResponse
- )}`
- );
- } else {
- set(
- matchingOperation,
- "error",
- JSON.stringify(operationCallbackResponse) ||
- `Unknown response format: ${JSON.stringify(
- operationCallbackResponse
- )}`
- );
- }
- }
- setOperationsState(operationsStateCopy);
- }
- } catch (error: any) {
- const statusCode = get(error, "status", "");
- const errorMessage = get(error, "response.data.message", "");
- // Handle status codes by ranges in case of error
- if (statusCode && statusCode >= 300 && statusCode < 400) {
- // 3xx Redirection status codes - treat as error for operations
- set(matchingOperation, "status", ProgressStatus.FAILED);
- set(
- matchingOperation,
- "error",
- JSON.stringify(errorMessage) ||
- `Redirection Error (${statusCode}): Operation requires manual intervention`
- );
- } else if (statusCode && statusCode >= 400 && statusCode < 500) {
- // 4xx Client error status codes
- set(matchingOperation, "status", ProgressStatus.FAILED);
- set(
- matchingOperation,
- "error",
- JSON.stringify(errorMessage) ||
- `Client Error (${statusCode}): Please check the request parameters`
- );
- } else if (statusCode && statusCode >= 500 && statusCode < 600) {
- // 5xx Server error status codes
- set(matchingOperation, "status", ProgressStatus.FAILED);
- set(
- matchingOperation,
- "error",
- JSON.stringify(errorMessage) ||
- `Server Error (${statusCode}): Please try again later or contact support`
- );
- } else {
- // Unknown status code or response exists but no status code
- set(matchingOperation, "status", ProgressStatus.FAILED);
- if (statusCode) {
- set(
- matchingOperation,
- "error",
- JSON.stringify(errorMessage) ||
- `Unknown Status Code (${statusCode}): ${JSON.stringify(
- errorMessage
- )}`
- );
- } else {
- set(
- matchingOperation,
- "error",
- JSON.stringify(errorMessage) ||
- `Unknown response format: ${JSON.stringify(errorMessage)}`
- );
- }
- }
- setOperationsState(operationsStateCopy);
- }
+ const statusCode = get(response, "[0].status", response?.status);
+ if (
+ (statusCode && statusCode >= 200 && statusCode < 300) ||
+ !response
+ ) {
+ set(updatedOperation, "status", ProgressStatus.COMPLETED);
+ delete updatedOperation.error;
+ } else {
+ set(updatedOperation, "status", ProgressStatus.FAILED);
+ set(
+ updatedOperation,
+ "error",
+ statusCode
+ ? `Unknown Status Code (${statusCode}): ${JSON.stringify(response)}`
+ : `Unknown response format: ${JSON.stringify(response)}`,
+ );
}
+ await persistAndContinue(operationsStateCopy, () =>
+ moveToNextOperation(operationsStateCopy, operationId),
+ );
+ } catch (error: any) {
+ await persistFailedOperation(operationId, error);
}
- }
+ };
- const handleMoveToNextOperation = () => {
- const currentActiveOperation = operationsState.find(
- (operation) => operation.id == activeOperationId
+ async function executeTask(
+ operationId: string | number | null = activeOperationId,
+ forceRetry = false,
+ ) {
+ if (operationId === null || startedTasks.current.has(operationId)) return;
+ startedTasks.current.add(operationId);
+ const matchingOperation = operationsStateRef.current.find(
+ (operation) => operation.id == operationId,
);
+ if (!matchingOperation) return;
+
if (
- currentActiveOperation &&
- isFinished(currentActiveOperation?.status || "")
+ !forceRetry &&
+ matchingOperation.status === ProgressStatus.IN_PROGRESS &&
+ matchingOperation.requestId
) {
- if (currentActiveOperation?.status !== ProgressStatus.FAILED) {
- if (activeOperationId == operationsState?.at(-1)?.id) {
- setCompletionStatus(true);
- } else {
- const currentActiveIndex = operationsState.findIndex(
- (operation) => operation.id == activeOperationId
- );
- setActiveOperationId(
- Number(operationsState[Number(currentActiveIndex) + 1]?.id)
- );
- }
- }
+ await trackCurrentRequestStatus(matchingOperation.requestId, operationId);
+ return;
}
- };
+ if (!forceRetry && matchingOperation.status === "QUEUED") {
+ await runOperationCallback(operationId);
+ return;
+ }
+ if (!forceRetry && isFinished(matchingOperation.status || "")) return;
+
+ const operationsStateCopy = cloneDeep(operationsStateRef.current);
+ const queuedOperation: any = operationsStateCopy.find(
+ (operation) => operation.id == operationId,
+ );
+ set(queuedOperation, "status", "QUEUED");
+ delete queuedOperation.error;
+ await persistAndContinue(
+ operationsStateCopy,
+ () => runOperationCallback(operationId),
+ "Ambari could not persist the task checkpoint before execution.",
+ );
+ }
const retryOperation = () => {
- startedTasks.current = startedTasks.current.filter((task: any) => {
- task != activeOperationId;
- });
- executeTask();
+ if (activeOperationId === null) return;
+ startedTasks.current.delete(activeOperationId);
+ void executeTask(activeOperationId, true);
};
useEffect(() => {
- if (activeOperationId >= 0) {
- executeTask();
+ if (activeOperationId !== null) {
+ void executeTask(activeOperationId);
}
}, [activeOperationId]);
useEffect(() => {
- if (dispatch) {
- dispatch(operationsState);
- }
- handleMoveToNextOperation();
- }, [JSON.stringify(operationsState)]);
-
- useEffect(() => {
- let activeIdx = -1;
- let toBeStartedIdx = -1;
- for (let i = operationsState.length - 1; i >= 0; i--) {
- if (
- get(operationsState[i], "requestId", "") ||
- get(operationsState[i], "status", "")
- ) {
- activeIdx = i;
- break;
- }
+ let toBeStartedIdx = 0;
+ while (
+ toBeStartedIdx < operations.length &&
+ operations[toBeStartedIdx].status === ProgressStatus.COMPLETED
+ ) {
+ startedTasks.current.add(operations[toBeStartedIdx].id);
+ toBeStartedIdx += 1;
}
-
- if (activeIdx === -1) {
- toBeStartedIdx = 0;
- } else {
- for (let i = 0; i <= activeIdx; i++) {
- if (
- get(operationsState[i], "requestId", "") ||
- get(operationsState[i], "status", "")
- ) {
- startedTasks.current.push(operationsState[i].id);
- if (isFinished(operationsState[i]?.status || "")) {
- if (operationsState[i]?.status === ProgressStatus.FAILED) {
- toBeStartedIdx = i;
- } else {
- toBeStartedIdx = i + 1;
- }
- } else {
- toBeStartedIdx = i;
- }
- }
- }
+ if (toBeStartedIdx >= operations.length && operations.length) {
+ setCompletionStatus(true);
+ return;
}
- setActiveOperationId(Number(operationsState?.[toBeStartedIdx]?.id));
- if(operationsState?.[toBeStartedIdx]?.requestId){
- trackCurrentRequestStatus(operationsState?.[toBeStartedIdx]?.requestId as number, operationsState?.[toBeStartedIdx]?.id as number);
+ const operation = operations[toBeStartedIdx];
+ if (operation && isFailed(operation.status || "")) {
+ startedTasks.current.add(operation.id);
}
+ setActiveOperationId(operation?.id ?? null);
+ }, []);
+ useEffect(() => {
+ return () => {
+ isMounted.current = false;
+ pollingTimers.current.forEach((timer) => window.clearTimeout(timer));
+ pollingTimers.current.clear();
+ };
}, []);
return (
<div className="p-3">
+ {persistenceError ? (
+ <Alert variant="danger">
+ {persistenceError}
+ <Button
+ size="sm"
+ className="ms-3"
+ disabled={isRetryingPersistence}
+ onClick={() => void retryPersistence()}
+ >
+ Retry checkpoint
+ </Button>
+ </Alert>
+ ) : null}
<Stack direction="vertical">
{operationsState.map((operation: any) => {
return (
@@ -378,6 +463,13 @@ function OperationsProgress({
/>
) : null}
+ {has(operation, "error") &&
+ !isFinished(operation.status) && (
+ <Alert variant="warning" className="scrollable-h15 mt-3">
+ {operation.error}
+ </Alert>
+ )}
+
{has(operation, "error") &&
isFinished(operation.status) &&
(errorCallback ? (
diff --git a/ambari-web/latest/src/components/StepWizard/WizardFooter.tsx b/ambari-web/latest/src/components/StepWizard/WizardFooter.tsx
index 93bfe765f7..7e4442f65f 100644
--- a/ambari-web/latest/src/components/StepWizard/WizardFooter.tsx
+++ b/ambari-web/latest/src/components/StepWizard/WizardFooter.tsx
@@ -32,6 +32,7 @@ interface PropTypes {
onCancel?: () => void;
lifted?: boolean;
sideItems?: any;
+ cancelConfirmationBody?: string;
}
function WizardFooter({
@@ -44,6 +45,7 @@ function WizardFooter({
isCancelEnabled = true,
lifted = false,
sideItems,
+ cancelConfirmationBody = "Are you sure you want to cancel the operation?",
}: PropTypes) {
const [showConfirmationModal, setShowConfirmationModal] = useState(false);
return (
@@ -63,7 +65,7 @@ function WizardFooter({
}}
options={{}}
modalTitle="Confirmation"
- modalBody="Are you sure you want to cancel the operation?"
+ modalBody={cancelConfirmationBody}
successCallback={() => {
onCancel();
}}
diff --git a/ambari-web/latest/src/components/StepWizard/index.tsx b/ambari-web/latest/src/components/StepWizard/index.tsx
index cd3a74635a..c34e58a634 100644
--- a/ambari-web/latest/src/components/StepWizard/index.tsx
+++ b/ambari-web/latest/src/components/StepWizard/index.tsx
@@ -22,6 +22,7 @@ import classNames from "classnames";
import { CheckLg } from "react-bootstrap-icons";
import ConfirmationModal from "../ConfirmationModal";
import { get } from "lodash";
+import { getVisibleStepNumbers } from "../../hooks/useStepWizard";
interface StepWizardProps {
wizardUtilities: any;
@@ -38,6 +39,8 @@ const StepWizard: FunctionComponent<StepWizardProps> = ({
const flushStateToDb = get(contextValue, "flushStateToDb", "");
const [jumpStep, setjumpStep] = useState(0);
const [showNavigationModal, setShowNavigationModal] = useState(false);
+ const visibleStepNumbers = getVisibleStepNumbers(wizardSteps);
+ const jumpStepDisplayNumber = visibleStepNumbers.indexOf(jumpStep) + 1;
return (
<div className="step-wizard h-95" style={{ position: "relative" }}>
<ConfirmationModal
@@ -46,7 +49,7 @@ const StepWizard: FunctionComponent<StepWizardProps> = ({
setShowNavigationModal(false);
}}
modalTitle="Navigation Warning"
- modalBody={`If you proceed to go back to Step ${jumpStep}, you will lose any changes you made.`}
+ modalBody={`If you proceed to go back to Step ${jumpStepDisplayNumber}, you will lose any changes you made.`}
successCallback={() => {
jumpToStep(jumpStep);
if (flushStateToDb) {
@@ -57,7 +60,7 @@ const StepWizard: FunctionComponent<StepWizardProps> = ({
/>
<div className="d-flex h-100">
<div className="wizard-nav p-2">
- {Object.keys(wizardSteps).map((currentStep) => {
+ {visibleStepNumbers.map((currentStep, visibleIndex) => {
const step = wizardSteps[currentStep];
return (
<div
@@ -87,7 +90,7 @@ const StepWizard: FunctionComponent<StepWizardProps> = ({
{step.completed ? (
<CheckLg className="text-white fw-5" />
) : (
- currentStep
+ visibleIndex + 1
)}
</div>
</div>
diff --git a/ambari-web/latest/src/hooks/useConfigsTags.ts b/ambari-web/latest/src/hooks/useConfigsTags.ts
index 6a4dd349f8..4b41651636 100644
--- a/ambari-web/latest/src/hooks/useConfigsTags.ts
+++ b/ambari-web/latest/src/hooks/useConfigsTags.ts
@@ -34,9 +34,13 @@ interface ConfigResponse {
export default function useHDFSConfigsTags() {
const { clusterName, services } = useContext(AppContext);
const selectedServices = map(services, "ServiceInfo.service_name");
-const [configsData, setConfigsData] = useState<any>({});
+ const [configsData, setConfigsData] = useState<any>({});
+ const [configsError, setConfigsError] = useState("");
+ const [isConfigsLoading, setIsConfigsLoading] = useState(true);
async function loadConfigsTags() {
const inferredTags: any = {};
+ setConfigsError("");
+ setIsConfigsLoading(true);
try {
const data: ConfigResponse = await ConfigsApi.loadConfigTags(clusterName);
const urlParams = [];
@@ -199,6 +203,7 @@ const [configsData, setConfigsData] = useState<any>({});
value: rangerKnoxPluginPropertiesTag,
};
}
+ }
if (selectedServices.includes("STORM")) {
if ("ranger-storm-audit" in data.Clusters.desired_configs) {
const rangerStormAuditTag =
@@ -283,23 +288,30 @@ const [configsData, setConfigsData] = useState<any>({});
};
}
}
- }
}
- try {
- const configsResponseData: any = await ConfigsApi.getConfigsByTags(
- clusterName,
- urlParams.join("|")
- );
- setConfigsData(configsResponseData);
- } catch (configErr) {
- setConfigsData({});
- }
- } catch (err) {
- return { error: "Error loading config tags", err };
+ const configsResponseData: any = await ConfigsApi.getConfigsByTags(
+ clusterName,
+ urlParams.join("|")
+ );
+ setConfigsData(configsResponseData);
+ } catch (error: any) {
+ setConfigsData({});
+ setConfigsError(
+ error?.response?.data?.message ||
+ error?.message ||
+ "Ambari could not load the current HDFS configurations."
+ );
+ } finally {
+ setIsConfigsLoading(false);
}
}
useEffect(()=>{
- loadConfigsTags()
+ void loadConfigsTags();
},[])
- return {configsData}
+ return {
+ configsData,
+ configsError,
+ isConfigsLoading,
+ reloadConfigs: loadConfigsTags,
+ };
}
diff --git a/ambari-web/latest/src/hooks/useKDCSessionState.test.tsx b/ambari-web/latest/src/hooks/useKDCSessionState.test.tsx
index f5e4de375a..b3990062f1 100644
--- a/ambari-web/latest/src/hooks/useKDCSessionState.test.tsx
+++ b/ambari-web/latest/src/hooks/useKDCSessionState.test.tsx
@@ -17,12 +17,21 @@
*/
import { act, renderHook } from "@testing-library/react";
-import type { PropsWithChildren } from "react";
+import type { ContextType, PropsWithChildren, ReactElement } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import adminApi from "../api/adminApi";
import { AppContext } from "../store/context";
import useKDCSessionState from "./useKDCSessionState";
+const modalMocks = vi.hoisted(() => ({
+ show: vi.fn(),
+ hide: vi.fn(),
+}));
+
+vi.mock("../store/ModalManager", () => ({
+ default: modalMocks,
+}));
+
vi.mock("../api/adminApi", () => ({
default: {
getKerberosSessionState: vi.fn(),
@@ -34,7 +43,7 @@ vi.mock("../api/adminApi", () => ({
const contextValue = {
clusterName: "c1",
isKerberosEnabled: true,
-} as any;
+} as ContextType<typeof AppContext>;
function wrapper({ children }: PropsWithChildren) {
return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
@@ -45,7 +54,36 @@ describe("useKDCSessionState", () => {
vi.clearAllMocks();
vi.mocked(adminApi.getSecurityStatus).mockResolvedValue({
Clusters: { security_type: "KERBEROS" },
- } as any);
+ });
+ });
+
+ it("reports invalid-KDC credential cancellation to the protected operation", async () => {
+ vi.mocked(adminApi.getSecurityType).mockResolvedValue({
+ items: [
+ {
+ configurations: [
+ { type: "kerberos-env", properties: { kdc_type: "mit-kdc" } },
+ ],
+ },
+ ],
+ });
+ vi.mocked(adminApi.getKerberosSessionState).mockResolvedValue({
+ Services: { attributes: { kdc_validation_result: "INVALID" } },
+ });
+ const callback = vi.fn();
+ const errorCallback = vi.fn();
+ const { result } = renderHook(() => useKDCSessionState(() => {}), { wrapper });
+
+ await result.current.getKDCSessionState(callback, errorCallback);
+ const popup = modalMocks.show.mock.calls.at(-1)?.[0] as ReactElement<{
+ onCancel: () => void;
+ }>;
+ popup.props.onCancel();
+
+ expect(callback).not.toHaveBeenCalled();
+ expect(errorCallback).toHaveBeenCalledWith(
+ expect.objectContaining({ message: "KDC credential entry was cancelled." }),
+ );
});
it("reports a security-type request failure instead of leaving the caller waiting", async () => {
@@ -69,7 +107,7 @@ describe("useKDCSessionState", () => {
properties: { kdc_type: "none" },
}],
}],
- } as any);
+ });
let operationCompleted = false;
const { result } = renderHook(() => useKDCSessionState(() => {}), { wrapper });
diff --git a/ambari-web/latest/src/hooks/useKDCSessionState.tsx b/ambari-web/latest/src/hooks/useKDCSessionState.tsx
index c2842ffefe..114e6f1371 100644
--- a/ambari-web/latest/src/hooks/useKDCSessionState.tsx
+++ b/ambari-web/latest/src/hooks/useKDCSessionState.tsx
@@ -74,11 +74,20 @@ function useKDCSessionState(_cancelHandler: unknown) {
if (result.toUpperCase() === "OK") {
await callback();
} else {
+ const reportCredentialFailure = (error: unknown) => {
+ errorCallback?.(error);
+ };
modalManager.show(
<InvalidKdcPopup
getKdcSessionState={() => {
void getKDCSessionState(callback, errorCallback);
}}
+ onCancel={() =>
+ reportCredentialFailure(
+ new Error("KDC credential entry was cancelled."),
+ )
+ }
+ onError={reportCredentialFailure}
/>
);
}
diff --git a/ambari-web/latest/src/types/StepWizard.ts b/ambari-web/latest/src/hooks/useStepWizard.test.ts
similarity index 50%
copy from ambari-web/latest/src/types/StepWizard.ts
copy to ambari-web/latest/src/hooks/useStepWizard.test.ts
index 95dcf46fde..9a9e2719ca 100644
--- a/ambari-web/latest/src/types/StepWizard.ts
+++ b/ambari-web/latest/src/hooks/useStepWizard.test.ts
@@ -16,14 +16,35 @@
* limitations under the License.
*/
-import { ReactNode } from "react";
+import { describe, expect, it } from "vitest";
+import {
+ getAdjacentVisibleStep,
+ getVisibleStepNumbers,
+} from "./useStepWizard";
-export interface Step {
- label: String;
- completed: Boolean;
- Component: ReactNode;
- canGoBack: Boolean;
- isNextEnabled: Boolean;
- onNext?:any;
- nextLabel?:String;
-}
+const step = (hidden = false) => ({
+ label: "Step",
+ completed: false,
+ Component: null,
+ canGoBack: true,
+ isNextEnabled: true,
+ hidden,
+});
+
+describe("step wizard navigation", () => {
+ it("skips hidden steps while retaining stable physical step IDs", () => {
+ const steps = {
+ 0: step(),
+ 1: step(),
+ 2: step(true),
+ 3: step(),
+ 4: step(true),
+ 5: step(),
+ };
+
+ expect(getVisibleStepNumbers(steps)).toEqual([0, 1, 3, 5]);
+ expect(getAdjacentVisibleStep(steps, 1, 1)).toBe(3);
+ expect(getAdjacentVisibleStep(steps, 5, -1)).toBe(3);
+ expect(getAdjacentVisibleStep(steps, 2, 1)).toBe(3);
+ });
+});
diff --git a/ambari-web/latest/src/hooks/useStepWizard.ts b/ambari-web/latest/src/hooks/useStepWizard.ts
index 5fb4fe3e02..1e24445af4 100644
--- a/ambari-web/latest/src/hooks/useStepWizard.ts
+++ b/ambari-web/latest/src/hooks/useStepWizard.ts
@@ -21,11 +21,41 @@ import { cloneDeep } from "lodash";
import { Step } from "../types/StepWizard";
import { useLocation, useNavigate, useParams } from "react-router-dom";
+export const getVisibleStepNumbers = (steps: { [key: number]: Step }) =>
+ Object.keys(steps)
+ .map(Number)
+ .sort((a, b) => a - b)
+ .filter((stepNumber) => !steps[stepNumber]?.hidden);
+
+export const getAdjacentVisibleStep = (
+ steps: { [key: number]: Step },
+ activeStep: number,
+ direction: 1 | -1,
+) => {
+ const visibleSteps = getVisibleStepNumbers(steps);
+ const currentIndex = visibleSteps.indexOf(activeStep);
+ if (currentIndex === -1) {
+ if (direction === 1) {
+ return visibleSteps.find((stepNumber) => stepNumber > activeStep);
+ }
+ return [...visibleSteps]
+ .reverse()
+ .find((stepNumber) => stepNumber < activeStep);
+ }
+ return visibleSteps[currentIndex + direction];
+};
+
const useStepWizard = (steps: any, initialActiveStep = 0, onCancel?: any) => {
const [activeStep, setActiveStep] = useState(initialActiveStep || 0);
const navigate = useNavigate();
const [wizardSteps, setWizardSteps] = useState<{ [key: number]: Step }>(
- steps
+ () =>
+ Object.fromEntries(
+ Object.entries(steps).map(([stepNumber, step]) => [
+ stepNumber,
+ { ...(step as Step) },
+ ]),
+ ),
);
const { stepNumber } = useParams();
const location = useLocation();
@@ -50,8 +80,13 @@ const useStepWizard = (steps: any, initialActiveStep = 0, onCancel?: any) => {
if (canProceed) {
wizardStepsCopy[activeStep].completed = true;
setWizardSteps(wizardStepsCopy);
- if (activeStep !== Object.keys(steps).length - 1) {
- setActiveStep(activeStep + 1);
+ const nextStep = getAdjacentVisibleStep(
+ wizardStepsCopy,
+ activeStep,
+ 1,
+ );
+ if (nextStep !== undefined) {
+ setActiveStep(nextStep);
}
}
} catch (error) {
@@ -59,17 +94,29 @@ const useStepWizard = (steps: any, initialActiveStep = 0, onCancel?: any) => {
}
}
};
- const handleNextImperitive = async () => {
+ const handleNextImperitive = async (targetStep?: number) => {
const wizardStepsCopy = { ...wizardSteps };
wizardStepsCopy[activeStep].completed = true;
- setActiveStep(Number(activeStep) + 1);
+ const nextStep =
+ targetStep ?? getAdjacentVisibleStep(wizardStepsCopy, activeStep, 1);
+ if (nextStep !== undefined) {
+ setActiveStep(nextStep);
+ }
setWizardSteps(wizardStepsCopy);
};
const handleBackImperitive = async () => {
const wizardStepsCopy = { ...wizardSteps };
+ const previousStep = getAdjacentVisibleStep(
+ wizardStepsCopy,
+ activeStep,
+ -1,
+ );
+ if (previousStep === undefined) {
+ return;
+ }
wizardStepsCopy[activeStep].completed = false;
- wizardStepsCopy[activeStep - 1].completed = false;
- setActiveStep(Number(activeStep) - 1);
+ wizardStepsCopy[previousStep].completed = false;
+ setActiveStep(previousStep);
setWizardSteps(wizardStepsCopy);
};
@@ -86,17 +133,27 @@ const useStepWizard = (steps: any, initialActiveStep = 0, onCancel?: any) => {
};
const handleBack = () => {
- let wizardStepsCopy = { ...wizardSteps };
+ const wizardStepsCopy = { ...wizardSteps };
+ const previousStep = getAdjacentVisibleStep(
+ wizardStepsCopy,
+ activeStep,
+ -1,
+ );
+ if (previousStep === undefined) {
+ return;
+ }
wizardStepsCopy[activeStep].completed = false;
- wizardStepsCopy[activeStep - 1].completed = false;
+ wizardStepsCopy[previousStep].completed = false;
setWizardSteps(wizardStepsCopy);
- if (activeStep !== 0) setActiveStep(activeStep - 1);
+ setActiveStep(previousStep);
};
const canJumpFromCurrentStep = (currentStep: number) => {
+ const targetStep = Number(currentStep);
return !(
- currentStep > activeStep ||
- (currentStep !== activeStep && !wizardSteps[activeStep]?.canGoBack)
+ wizardSteps[targetStep]?.hidden ||
+ targetStep > activeStep ||
+ (targetStep !== activeStep && !wizardSteps[activeStep]?.canGoBack)
);
};
@@ -105,20 +162,46 @@ const useStepWizard = (steps: any, initialActiveStep = 0, onCancel?: any) => {
}, [activeStep]);
const jumpToStep = (stepNumber: number, isImperitiveJump = false) => {
- if (canJumpFromCurrentStep(stepNumber) || isImperitiveJump) {
+ let targetStep = stepNumber;
+ if (wizardSteps[targetStep]?.hidden) {
+ targetStep =
+ getAdjacentVisibleStep(wizardSteps, targetStep, 1) ??
+ getAdjacentVisibleStep(wizardSteps, targetStep, -1) ??
+ targetStep;
+ }
+ if (canJumpFromCurrentStep(targetStep) || isImperitiveJump) {
const wizardStepsCopy = cloneDeep(wizardSteps);
for (const step in wizardStepsCopy) {
- if (Number(step) >= stepNumber) {
+ if (Number(step) >= targetStep) {
wizardStepsCopy[step].completed = false;
} else {
wizardStepsCopy[step].completed = true;
}
}
setWizardSteps(wizardStepsCopy);
- setActiveStep(stepNumber);
+ setActiveStep(targetStep);
}
};
+ const setStepsHidden = (stepNumbers: number[], hidden: boolean) => {
+ setWizardSteps((currentSteps) => {
+ const nextSteps = { ...currentSteps };
+ stepNumbers.forEach((stepNumber) => {
+ if (nextSteps[stepNumber]) {
+ nextSteps[stepNumber] = { ...nextSteps[stepNumber], hidden };
+ }
+ });
+ return nextSteps;
+ });
+ };
+
+ const previousStepNumber = getAdjacentVisibleStep(
+ wizardSteps,
+ activeStep,
+ -1,
+ );
+ const nextStepNumber = getAdjacentVisibleStep(wizardSteps, activeStep, 1);
+
return {
activeStep,
wizardSteps,
@@ -131,9 +214,11 @@ const useStepWizard = (steps: any, initialActiveStep = 0, onCancel?: any) => {
initialiseNextCallback,
handleNextImperitive,
handleBackImperitive,
+ setStepsHidden,
currentStep: wizardSteps[activeStep],
- prevStepNumber: activeStep - 1,
- onCancel
+ prevStepNumber: previousStepNumber,
+ nextStepNumber,
+ onCancel,
};
};
diff --git a/ambari-web/latest/src/router/RoutesList.tsx b/ambari-web/latest/src/router/RoutesList.tsx
index a5a5863f65..9bf47d0567 100644
--- a/ambari-web/latest/src/router/RoutesList.tsx
+++ b/ambari-web/latest/src/router/RoutesList.tsx
@@ -16,7 +16,8 @@
* limitations under the License.
*/
-import { Navigate, Outlet, RouteObject } from "react-router-dom";
+import { ReactNode } from "react";
+import { Navigate, Outlet, RouteObject, useParams } from "react-router-dom";
import { AuthenticatedApplication, LandingRoute } from "../AppLoader";
import { ProtectedRoute } from "../components/AuthGuard";
import AdminRouteGuard from "../components/AdminRouteGuard";
@@ -52,6 +53,19 @@ import FeatureRouteGuard from "../components/FeatureRouteGuard";
import AdminViewRouteGuard from "../components/AdminViewRouteGuard";
import ServiceOperationRouteGuard from "../components/ServiceOperationRouteGuard";
+const NameNodePersistenceRouteGuard = ({ children }: { children: ReactNode }) => {
+ const { componentName } = useParams();
+ if (componentName !== "NameNode") return <>{children}</>;
+ return (
+ <ProtectedRoute
+ requireAuthorization="CLUSTER.MANAGE_USER_PERSISTED_DATA"
+ redirectTo="/main/dashboard/metrics"
+ >
+ {children}
+ </ProtectedRoute>
+ );
+};
+
const RoutesList: RouteObject[] = [
{
path: "/",
@@ -147,14 +161,16 @@ const RoutesList: RouteObject[] = [
{
path: "highAvailability/:componentName/enable/:stepNumber",
element: (
- <ProtectedRoute
- requireAuthorization="SERVICE.ENABLE_HA"
- redirectTo="/main/dashboard/metrics"
- >
- <ServiceOperationRouteGuard>
- <ServiceLoader />
- </ServiceOperationRouteGuard>
- </ProtectedRoute>
+ <NameNodePersistenceRouteGuard>
+ <ProtectedRoute
+ requireAuthorization="SERVICE.ENABLE_HA"
+ redirectTo="/main/dashboard/metrics"
+ >
+ <ServiceOperationRouteGuard>
+ <ServiceLoader />
+ </ServiceOperationRouteGuard>
+ </ProtectedRoute>
+ </NameNodePersistenceRouteGuard>
),
},
{
@@ -173,14 +189,21 @@ const RoutesList: RouteObject[] = [
{
path: "highAvailability/:componentName/manage/:stepNumber",
element: (
- <ProtectedRoute
- requireAuthorization="SERVICE.ENABLE_HA"
- redirectTo="/main/dashboard/metrics"
- >
- <ServiceOperationRouteGuard>
- <ServiceLoader />
- </ServiceOperationRouteGuard>
- </ProtectedRoute>
+ <FeatureRouteGuard feature="manageJournalNode">
+ <ProtectedRoute
+ requireAuthorization="CLUSTER.MANAGE_USER_PERSISTED_DATA"
+ redirectTo="/main/dashboard/metrics"
+ >
+ <ProtectedRoute
+ requireAuthorization="SERVICE.RUN_CUSTOM_COMMAND, SERVICE.RUN_SERVICE_CHECK, SERVICE.TOGGLE_MAINTENANCE, SERVICE.ENABLE_HA, HOST.ADD_DELETE_COMPONENTS"
+ redirectTo="/main/dashboard/metrics"
+ >
+ <ServiceOperationRouteGuard>
+ <ServiceLoader />
+ </ServiceOperationRouteGuard>
+ </ProtectedRoute>
+ </ProtectedRoute>
+ </FeatureRouteGuard>
),
},
],
diff --git a/ambari-web/latest/src/screens/Services/Actions.tsx b/ambari-web/latest/src/screens/Services/Actions.tsx
index 551ab44b24..99f78b1dd5 100644
--- a/ambari-web/latest/src/screens/Services/Actions.tsx
+++ b/ambari-web/latest/src/screens/Services/Actions.tsx
@@ -24,6 +24,7 @@ import {
cloneDeep,
filter,
find,
+ flatten,
get,
isEmpty,
lowerCase,
@@ -138,6 +139,12 @@ export const Actions = ({ serviceName, className }: ActionsProps) => {
const canAddDeleteComponents = hasAuthorization("HOST.ADD_DELETE_COMPONENTS");
const canEnableHA = hasAuthorization("SERVICE.ENABLE_HA");
const canMoveComponents = hasAuthorization("SERVICE.MOVE");
+ const canPersistWorkflow = hasAuthorization(
+ "CLUSTER.MANAGE_USER_PERSISTED_DATA",
+ );
+ const canManageJournalNodesPermission = hasAuthorization(
+ "SERVICE.RUN_CUSTOM_COMMAND, SERVICE.RUN_SERVICE_CHECK, SERVICE.TOGGLE_MAINTENANCE, SERVICE.ENABLE_HA",
+ );
// Check if user has any service-related permissions at all
// This matches Ember.js logic where Actions dropdown is hidden if no permissions
@@ -958,17 +965,24 @@ export const Actions = ({ serviceName, className }: ActionsProps) => {
}
);
const isHAEnabled = isHFDSInstalled && !hasSNameNode;
- if (!isHAEnabled) {
- return false;
- }
- const allJournalNodes = find(hdfsModel?.["slaveComponents"], [
- "componentName",
- "JOURNALNODE",
- ])?.totalCount;
- if (allHostNames.length === 3 && allJournalNodes === 3) {
+ if (!supports.manageJournalNode || !isHAEnabled) {
return false;
}
- return true;
+ const journalNodeComponent = find(
+ flatten([
+ hdfsModel?.masterComponents || [],
+ hdfsModel?.slaveComponents || [],
+ ]),
+ ["componentName", "JOURNALNODE"],
+ );
+ const allJournalNodes = Math.max(
+ journalNodeComponent?.totalCount || 0,
+ hdfsModel?.journalNodes?.length || 0,
+ );
+ return (
+ allJournalNodes >= 3 &&
+ (allHostNames.length > allJournalNodes || allJournalNodes > 3)
+ );
};
const calcBatchPayload = async (
@@ -1959,14 +1973,17 @@ export const Actions = ({ serviceName, className }: ActionsProps) => {
)) : null;
})()}
{/* High Availability Features - Requires SERVICE.ENABLE_HA authorization */}
- {canEnableHA && serviceName === "HDFS" && !isHAEnabled() && (
+ {canEnableHA && canPersistWorkflow && serviceName === "HDFS" && !isHAEnabled() && (
<EnableHighAvailibilityNameNode />
)}
{/* Add New HDFS Namespace (Federation) - Requires SERVICE.ENABLE_HA authorization, HDFS service, and FEDERATION serviceType (matches Ember.js logic) */}
{canEnableHA && serviceName === "HDFS" && (
<EnableNamenodeFederation />
)}
- {canEnableHA && serviceName === "HDFS" && canManageJournalNodes() && (
+ {canManageJournalNodesPermission &&
+ canPersistWorkflow &&
+ serviceName === "HDFS" &&
+ canManageJournalNodes() && (
<ManageJournalNodes />
)}
{canEnableHA && serviceName === "YARN" && !isRMHAEnabled() && (
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/haWorkflowUtils.test.ts b/ambari-web/latest/src/screens/Services/highAvailibility/haWorkflowUtils.test.ts
new file mode 100644
index 0000000000..2b294a2e55
--- /dev/null
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/haWorkflowUtils.test.ts
@@ -0,0 +1,383 @@
+/**
+ * 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, vi } from "vitest";
+import {
+ buildDesiredConfigQuery,
+ buildJournalNodeSharedEditsConfigs,
+ evaluateCheckpointSet,
+ evaluateJournalNodeFormatSet,
+ evaluateNameNodeCheckpoint,
+ getHdfsNamespaces,
+ getHdfsUser,
+ getJournalNodeDirectories,
+ getJournalNodeChangeSet,
+ getRangerReconfigureSiteGroups,
+ isValidNameNodeHaAssignment,
+ mergeReviewedConfigs,
+ mergeSavedOperations,
+ updateReviewedConfigValue,
+} from "./haWorkflowUtils";
+
+const checkpoint = (host: string, delta = 1) => ({
+ HostRoles: { host_name: host, desired_state: "STARTED" },
+ metrics: {
+ dfs: {
+ namenode: {
+ Safemode: "Safe mode is ON",
+ JournalTransactionInfo: JSON.stringify({
+ LastAppliedOrWrittenTxId: 10,
+ MostRecentCheckpointTxId: 10 - delta,
+ }),
+ },
+ },
+ },
+});
+
+describe("NameNode HA workflow utilities", () => {
+ type TestOperation = {
+ id: string | number;
+ label: string;
+ callback: () => Promise<unknown>;
+ status?: string;
+ };
+
+ it("reads the configured HDFS user and normalizes namespace model shapes", () => {
+ expect(
+ getHdfsUser({
+ items: [
+ {
+ configurations: [
+ { type: "hadoop-env", properties: { hdfs_user: "hdfs-svc" } },
+ ],
+ },
+ ],
+ }),
+ ).toBe("hdfs-svc");
+ expect(
+ getHdfsNamespaces({
+ namespaces: [
+ { nameSpace: "ns1", hostNames: ["nn1", "nn2", "nn1"] },
+ ],
+ }),
+ ).toEqual([{ name: "ns1", hosts: ["nn1", "nn2"] }]);
+ expect(
+ getHdfsNamespaces({
+ federationNamespaces: [{ name: "ns2", hosts: ["nn3", "nn4"] }],
+ }),
+ ).toEqual([{ name: "ns2", hosts: ["nn3", "nn4"] }]);
+ });
+
+ it("requires every JournalNode directory property and deduplicates paths", () => {
+ expect(
+ getJournalNodeDirectories(
+ {
+ federationNamespaces: [
+ { name: "ns1", hosts: ["nn1", "nn2"] },
+ { name: "ns2", hosts: ["nn3", "nn4"] },
+ ],
+ },
+ {
+ "dfs.journalnode.edits.dir.ns1": "/hadoop/hdfs/journal",
+ "dfs.journalnode.edits.dir.ns2": "/hadoop/hdfs/journal",
+ },
+ ),
+ ).toEqual({
+ directories: ["/hadoop/hdfs/journal"],
+ missingProperties: [],
+ });
+ expect(
+ getJournalNodeDirectories(
+ {
+ federationNamespaces: [
+ { name: "ns1", hosts: ["nn1", "nn2"] },
+ { name: "ns2", hosts: ["nn3", "nn4"] },
+ ],
+ },
+ { "dfs.journalnode.edits.dir.ns1": "/jn/ns1" },
+ ).missingProperties,
+ ).toEqual(["dfs.journalnode.edits.dir.ns2"]);
+ });
+
+ it("rejects malformed checkpoint data without throwing", () => {
+ const result = evaluateNameNodeCheckpoint({
+ HostRoles: { desired_state: "STARTED" },
+ metrics: {
+ dfs: {
+ namenode: {
+ Safemode: "Safe mode is ON",
+ JournalTransactionInfo: "not-json",
+ },
+ },
+ },
+ });
+
+ expect(result.ready).toBe(false);
+ expect(result.error).toContain("malformed");
+ });
+
+ it("requires the complete JournalNode host set", () => {
+ const formatted = (value: boolean) => ({
+ metrics: {
+ dfs: {
+ journalnode: {
+ journalsStatus: JSON.stringify({
+ nameservice1: { Formatted: String(value) },
+ }),
+ },
+ },
+ },
+ });
+
+ expect(
+ evaluateJournalNodeFormatSet(
+ ["jn1", "jn2", "jn3", "jn4"],
+ { jn1: formatted(true), jn2: formatted(true), jn3: formatted(true) },
+ "nameservice1",
+ ),
+ ).toMatchObject({ ready: false, missingHosts: ["jn4"] });
+
+ expect(
+ evaluateJournalNodeFormatSet(
+ ["jn1", "jn2", "jn3", "jn4"],
+ {
+ jn1: formatted(true),
+ jn2: formatted(true),
+ jn3: formatted(true),
+ jn4: formatted(true),
+ },
+ "nameservice1",
+ ).ready,
+ ).toBe(true);
+ });
+
+ it("rejects missing and duplicate Federation checkpoint responses", () => {
+ expect(
+ evaluateCheckpointSet(["nn1", "nn2"], [checkpoint("nn1")]).ready,
+ ).toBe(false);
+ expect(
+ evaluateCheckpointSet(
+ ["nn1", "nn2"],
+ [checkpoint("nn1"), checkpoint("nn1")],
+ ).ready,
+ ).toBe(false);
+ expect(
+ evaluateCheckpointSet(
+ ["nn1", "nn2"],
+ [checkpoint("nn2"), checkpoint("nn1")],
+ ).ready,
+ ).toBe(true);
+ });
+
+ it("builds one Federation shared-edits property per nameservice", () => {
+ const configs = buildJournalNodeSharedEditsConfigs(
+ ["ns1", "ns2"],
+ ["jn3", "jn1", "jn2"],
+ true,
+ );
+
+ expect(configs.map((item) => item.name)).toEqual([
+ "dfs.namenode.shared.edits.dir.ns1",
+ "dfs.namenode.shared.edits.dir.ns2",
+ ]);
+ expect(configs[0].value).toBe(
+ "qjournal://jn1:8485;jn2:8485;jn3:8485/ns1",
+ );
+ });
+
+ it("computes add, delete, delete-only, and no-op JournalNode modes", () => {
+ expect(
+ getJournalNodeChangeSet(
+ [
+ { component: "JOURNALNODE", hostName: "jn1" },
+ { component: "JOURNALNODE", hostName: "jn3" },
+ ],
+ ["jn1", "jn2"],
+ ),
+ ).toMatchObject({
+ addedHosts: ["jn3"],
+ deletedHosts: ["jn2"],
+ isDeleteOnly: false,
+ isNoOp: false,
+ });
+ expect(
+ getJournalNodeChangeSet(
+ [{ component: "JOURNALNODE", hostName: "jn1" }],
+ ["jn1", "jn2"],
+ ).isDeleteOnly,
+ ).toBe(true);
+ });
+
+ it("requires one additional NameNode and at least three unique JournalNodes", () => {
+ expect(
+ isValidNameNodeHaAssignment([
+ { component: "NAMENODE", hostName: "nn1", isInstalled: true },
+ { component: "NAMENODE", hostName: "nn2", isInstalled: false },
+ { component: "JOURNALNODE", hostName: "jn1" },
+ { component: "JOURNALNODE", hostName: "jn2" },
+ { component: "JOURNALNODE", hostName: "jn3" },
+ ]),
+ ).toBe(true);
+ expect(
+ isValidNameNodeHaAssignment([
+ { component: "NAMENODE", hostName: "nn1", isInstalled: true },
+ { component: "NAMENODE", hostName: "nn1", isInstalled: false },
+ { component: "JOURNALNODE", hostName: "jn1" },
+ { component: "JOURNALNODE", hostName: "jn1" },
+ { component: "JOURNALNODE", hostName: "jn3" },
+ ]),
+ ).toBe(false);
+ });
+
+ it("restores operation status without replacing live callbacks", async () => {
+ const callback = vi.fn().mockResolvedValue({ status: 200 });
+ const operations = mergeSavedOperations<TestOperation>(
+ [{ id: 1, label: "Install", callback }],
+ [{ id: 1, status: "FAILED", callback: vi.fn() }],
+ );
+
+ await operations[0].callback();
+ expect(callback).toHaveBeenCalledOnce();
+ expect(operations[0].status).toBe("FAILED");
+ });
+
+ it("migrates saved numeric task IDs to stable semantic IDs by label", () => {
+ const callback = vi.fn().mockResolvedValue(undefined);
+ const operations = mergeSavedOperations<TestOperation>(
+ [{ id: "startRanger", label: "Start Ranger", callback }],
+ [{ id: 3, label: "Start Ranger", status: "COMPLETED" }],
+ );
+
+ expect(operations[0]).toMatchObject({
+ id: "startRanger",
+ label: "Start Ranger",
+ status: "COMPLETED",
+ });
+ expect(operations[0].callback).toBe(callback);
+ });
+
+ it("builds the current desired-config query and rejects missing tags", () => {
+ expect(
+ buildDesiredConfigQuery(
+ {
+ "hdfs-site": { tag: "version1" },
+ "core-site": { tag: "version2" },
+ },
+ ["hdfs-site", "core-site", "hdfs-site"],
+ ),
+ ).toBe(
+ "(type=hdfs-site&tag=version1)|(type=core-site&tag=version2)",
+ );
+ expect(() =>
+ buildDesiredConfigQuery({}, ["hdfs-site"]),
+ ).toThrow("current hdfs-site configuration tag is missing");
+ });
+
+ it("overlays reviewed HA properties on current secure configs", () => {
+ expect(
+ mergeReviewedConfigs(
+ {
+ items: [
+ {
+ type: "hdfs-site",
+ properties: {
+ currentSecurityProperty: "from-agent-install",
+ "dfs.namenode.rpc-address": "old-host:8020",
+ },
+ properties_attributes: { final: { currentSecurityProperty: "true" } },
+ },
+ ],
+ },
+ {
+ items: [
+ {
+ type: "hdfs-site",
+ properties: { "dfs.nameservices": "nameservice1" },
+ },
+ ],
+ },
+ { "hdfs-site": ["dfs.namenode.rpc-address"] },
+ ).items[0],
+ ).toEqual({
+ type: "hdfs-site",
+ properties: {
+ currentSecurityProperty: "from-agent-install",
+ "dfs.nameservices": "nameservice1",
+ },
+ properties_attributes: { final: { currentSecurityProperty: "true" } },
+ });
+ });
+
+ it("writes an edited Review value into the submitted site snapshot", () => {
+ const original = {
+ items: [
+ {
+ type: "hdfs-site",
+ properties: { "dfs.journalnode.edits.dir": "/old/journal" },
+ },
+ ],
+ };
+
+ const updated = updateReviewedConfigValue(
+ original,
+ "hdfs-site",
+ "dfs.journalnode.edits.dir",
+ "/new/journal",
+ );
+
+ expect(updated.items[0].properties?.["dfs.journalnode.edits.dir"]).toBe(
+ "/new/journal",
+ );
+ expect(original.items[0].properties["dfs.journalnode.edits.dir"]).toBe(
+ "/old/journal",
+ );
+ });
+
+ it("groups all installed Ranger audit and plugin sites", () => {
+ const config = (type: string) => ({
+ type,
+ properties: { "xasecure.audit.destination.hdfs.dir": "hdfs://ns/audit" },
+ });
+ expect(
+ getRangerReconfigureSiteGroups(
+ ["YARN", "STORM", "KAFKA", "KNOX", "ATLAS", "HIVE", "RANGER_KMS"],
+ [
+ config("ranger-yarn-audit"),
+ config("ranger-storm-plugin-properties"),
+ config("ranger-storm-audit"),
+ config("ranger-kafka-audit"),
+ config("ranger-knox-plugin-properties"),
+ config("ranger-knox-audit"),
+ config("ranger-atlas-audit"),
+ config("ranger-hive-plugin-properties"),
+ config("ranger-hive-audit"),
+ config("ranger-kms-audit"),
+ ],
+ ),
+ ).toEqual([
+ ["ranger-env"],
+ ["ranger-yarn-audit"],
+ ["ranger-storm-plugin-properties", "ranger-storm-audit"],
+ ["ranger-kafka-audit"],
+ ["ranger-knox-plugin-properties", "ranger-knox-audit"],
+ ["ranger-atlas-audit"],
+ ["ranger-hive-plugin-properties", "ranger-hive-audit"],
+ ["ranger-kms-audit"],
+ ]);
+ });
+});
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/haWorkflowUtils.ts b/ambari-web/latest/src/screens/Services/highAvailibility/haWorkflowUtils.ts
new file mode 100644
index 0000000000..f90e2a6113
--- /dev/null
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/haWorkflowUtils.ts
@@ -0,0 +1,505 @@
+/**
+ * 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.
+ */
+
+export interface CheckpointEvaluation {
+ ready: boolean;
+ started: boolean;
+ error?: string;
+}
+
+export interface JournalNodeFormatEvaluation {
+ ready: boolean;
+ missingHosts: string[];
+ invalidHosts: string[];
+ error?: string;
+}
+
+export interface JournalNodeChangeSet {
+ finalHosts: string[];
+ addedHosts: string[];
+ deletedHosts: string[];
+ isDeleteOnly: boolean;
+ isNoOp: boolean;
+}
+
+export interface HdfsNamespace {
+ name: string;
+ hosts: string[];
+}
+
+export interface JournalNodeDirectoryEvaluation {
+ directories: string[];
+ missingProperties: string[];
+}
+
+type UnknownRecord = Record<string, unknown>;
+
+interface MasterComponentHost extends UnknownRecord {
+ component?: string;
+ component_name?: string;
+ hostName?: string;
+ selectedHost?: string;
+ isInstalled?: boolean;
+}
+
+interface ConfigSite extends UnknownRecord {
+ type?: string;
+ properties?: Record<string, string>;
+}
+
+const asArray = (value: unknown): unknown[] =>
+ Array.isArray(value) ? value : [];
+
+export function getHdfsUser(configData: unknown, fallback = "hdfs"): string {
+ const configurations = asArray(asRecord(configData)?.items).flatMap(
+ (item) => asArray(asRecord(item)?.configurations),
+ );
+ const hadoopEnv = configurations
+ .map(asRecord)
+ .find((config) => config?.type === "hadoop-env");
+ const hdfsUser = asRecord(hadoopEnv?.properties)?.hdfs_user;
+ return typeof hdfsUser === "string" && hdfsUser ? hdfsUser : fallback;
+}
+
+export function getHdfsNamespaces(hdfsModel: unknown): HdfsNamespace[] {
+ const model = asRecord(hdfsModel);
+ const federationNamespaces = asArray(model?.federationNamespaces);
+ const namespaceData = federationNamespaces.length
+ ? federationNamespaces
+ : asArray(model?.namespaces);
+ return namespaceData
+ .map((namespaceValue) => {
+ const namespace = asRecord(namespaceValue);
+ const name = namespace?.name || namespace?.nameSpace;
+ const hosts = namespace?.hosts || namespace?.hostNames;
+ return {
+ name: typeof name === "string" ? name : "",
+ hosts: asArray(hosts).filter(
+ (host): host is string => typeof host === "string" && Boolean(host),
+ ),
+ };
+ })
+ .filter(
+ (namespace: HdfsNamespace) =>
+ namespace.name && namespace.hosts.some(Boolean),
+ )
+ .map((namespace: HdfsNamespace) => ({
+ ...namespace,
+ hosts: [...new Set(namespace.hosts.filter(Boolean))],
+ }));
+}
+
+export function getJournalNodeDirectories(
+ hdfsModel: unknown,
+ configProperties: Record<string, string> = {},
+): JournalNodeDirectoryEvaluation {
+ const namespaces = getHdfsNamespaces(hdfsModel);
+ const propertyNames =
+ namespaces.length > 1
+ ? namespaces.map(
+ (namespace) => `dfs.journalnode.edits.dir.${namespace.name}`,
+ )
+ : ["dfs.journalnode.edits.dir"];
+ const missingProperties = propertyNames.filter(
+ (propertyName) => !configProperties[propertyName]?.trim(),
+ );
+ const directories = [
+ ...new Set(
+ propertyNames
+ .map((propertyName) => configProperties[propertyName]?.trim())
+ .filter(Boolean),
+ ),
+ ] as string[];
+
+ return { directories, missingProperties };
+}
+
+export function isValidNameNodeHaAssignment(
+ masterComponentHosts: MasterComponentHost[] = [],
+): boolean {
+ const hostFor = (item: MasterComponentHost) =>
+ item.hostName || item.selectedHost;
+ const nameNodes = masterComponentHosts.filter(
+ (item) =>
+ item.component === "NAMENODE" || item.component_name === "NAMENODE",
+ );
+ const journalNodes = masterComponentHosts.filter(
+ (item) =>
+ item.component === "JOURNALNODE" ||
+ item.component_name === "JOURNALNODE",
+ );
+ const installedNameNodes = nameNodes.filter((item) => item.isInstalled);
+ const additionalNameNodes = nameNodes.filter((item) => !item.isInstalled);
+ const uniqueNameNodeHosts = new Set(nameNodes.map(hostFor).filter(Boolean));
+ const uniqueJournalNodeHosts = new Set(
+ journalNodes.map(hostFor).filter(Boolean),
+ );
+
+ return (
+ installedNameNodes.length === 1 &&
+ additionalNameNodes.length === 1 &&
+ uniqueNameNodeHosts.size === nameNodes.length &&
+ journalNodes.length >= 3 &&
+ uniqueJournalNodeHosts.size === journalNodes.length
+ );
+}
+
+type Operation = {
+ id: string | number;
+ callback: () => Promise<unknown>;
+ [key: string]: unknown;
+};
+
+function asRecord(value: unknown): UnknownRecord | null {
+ if (value && typeof value === "object" && !Array.isArray(value)) {
+ return value as UnknownRecord;
+ }
+ if (typeof value !== "string" || !value.trim()) {
+ return null;
+ }
+ try {
+ const parsed = JSON.parse(value);
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
+ ? parsed
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+export function evaluateNameNodeCheckpoint(data: unknown): CheckpointEvaluation {
+ const root = asRecord(data);
+ const started = asRecord(root?.HostRoles)?.desired_state === "STARTED";
+ const namenode = asRecord(
+ asRecord(asRecord(root?.metrics)?.dfs)?.namenode,
+ );
+ const safeMode = namenode?.Safemode;
+ const transactionInfo = asRecord(
+ namenode?.JournalTransactionInfo,
+ );
+ if (!transactionInfo) {
+ return {
+ ready: false,
+ started,
+ error: "NameNode checkpoint transaction data is missing or malformed.",
+ };
+ }
+
+ const lastWritten = Number(transactionInfo.LastAppliedOrWrittenTxId);
+ const checkpoint = Number(transactionInfo.MostRecentCheckpointTxId);
+ if (!Number.isFinite(lastWritten) || !Number.isFinite(checkpoint)) {
+ return {
+ ready: false,
+ started,
+ error: "NameNode checkpoint transaction IDs are invalid.",
+ };
+ }
+
+ return {
+ ready: Boolean(safeMode) && lastWritten - checkpoint <= 1,
+ started,
+ };
+}
+
+export function evaluateJournalNodeFormatted(
+ data: unknown,
+ nameserviceId: string,
+): { ready: boolean; error?: string } {
+ const root = asRecord(data);
+ const journalnode = asRecord(
+ asRecord(asRecord(root?.metrics)?.dfs)?.journalnode,
+ );
+ const journalsStatus = asRecord(journalnode?.journalsStatus);
+ if (!journalsStatus) {
+ return {
+ ready: false,
+ error: "JournalNode formatted status is missing or malformed.",
+ };
+ }
+ const nameserviceStatus = journalsStatus[nameserviceId];
+ if (!nameserviceStatus) {
+ return {
+ ready: false,
+ error: `JournalNode did not report nameservice ${nameserviceId}.`,
+ };
+ }
+ return {
+ ready:
+ String(asRecord(nameserviceStatus)?.Formatted).toLowerCase() === "true",
+ };
+}
+
+export function evaluateJournalNodeFormatSet(
+ expectedHosts: string[],
+ responses: Record<string, unknown>,
+ nameserviceId: string,
+): JournalNodeFormatEvaluation {
+ const hosts = [...new Set(expectedHosts)].sort();
+ const missingHosts = hosts.filter((host) => !(host in responses));
+ const invalidHosts: string[] = [];
+ let firstError = "";
+
+ hosts.forEach((host) => {
+ if (!(host in responses)) {
+ return;
+ }
+ const evaluation = evaluateJournalNodeFormatted(
+ responses[host],
+ nameserviceId,
+ );
+ if (!evaluation.ready) {
+ invalidHosts.push(host);
+ firstError ||= evaluation.error || "JournalNode is not formatted yet.";
+ }
+ });
+
+ return {
+ ready: hosts.length >= 3 && !missingHosts.length && !invalidHosts.length,
+ missingHosts,
+ invalidHosts,
+ error: firstError || undefined,
+ };
+}
+
+export function evaluateCheckpointSet(
+ expectedHosts: string[],
+ items: unknown[],
+): CheckpointEvaluation {
+ const hosts = [...new Set(expectedHosts)].sort();
+ const responseHosts = items
+ .map((item) => asRecord(asRecord(item)?.HostRoles)?.host_name)
+ .filter((host): host is string => typeof host === "string" && Boolean(host));
+ const uniqueResponseHosts = [...new Set(responseHosts)].sort();
+
+ if (
+ hosts.length !== items.length ||
+ hosts.length !== uniqueResponseHosts.length ||
+ hosts.some((host, index) => host !== uniqueResponseHosts[index])
+ ) {
+ return {
+ ready: false,
+ started: false,
+ error: "Ambari returned an incomplete or duplicate NameNode checkpoint set.",
+ };
+ }
+
+ const evaluations = items.map(evaluateNameNodeCheckpoint);
+ return {
+ ready: evaluations.every((item) => item.ready),
+ started: evaluations.every((item) => item.started),
+ error: evaluations.find((item) => item.error)?.error,
+ };
+}
+
+export function buildJournalNodeSharedEditsConfigs(
+ nameserviceIds: string[],
+ journalNodeHosts: string[],
+ federated: boolean,
+) {
+ const hosts = [...new Set(journalNodeHosts)].sort();
+ const nameservices = [...new Set(nameserviceIds.filter(Boolean))];
+ const journalQuorum = hosts.map((host) => `${host}:8485`).join(";");
+ const targetNameservices = federated ? nameservices : nameservices.slice(0, 1);
+
+ return targetNameservices.map((nameserviceId) => {
+ const name = federated
+ ? `dfs.namenode.shared.edits.dir.${nameserviceId}`
+ : "dfs.namenode.shared.edits.dir";
+ const value = `qjournal://${journalQuorum}/${nameserviceId}`;
+ return {
+ name,
+ displayName: name,
+ description:
+ "The URI which identifies the group of JournalNodes used for shared edits.",
+ isReconfigurable: false,
+ isOverridable: false,
+ recommendedValue: value,
+ value,
+ changedValue: value,
+ category: "HDFS",
+ filename: "hdfs-site",
+ serviceName: "MISC",
+ };
+ });
+}
+
+export function getJournalNodeChangeSet(
+ masterComponentHosts: MasterComponentHost[] = [],
+ originalHosts: string[] = [],
+): JournalNodeChangeSet {
+ const finalHosts = [
+ ...new Set(
+ masterComponentHosts
+ .filter(
+ (item) =>
+ item.component === "JOURNALNODE" ||
+ item.component_name === "JOURNALNODE",
+ )
+ .map((item) => item.hostName || item.selectedHost)
+ .filter(
+ (host): host is string => typeof host === "string" && Boolean(host),
+ ),
+ ),
+ ].sort();
+ const originals = [...new Set(originalHosts.filter(Boolean))].sort();
+ const addedHosts = finalHosts.filter((host) => !originals.includes(host));
+ const deletedHosts = originals.filter((host) => !finalHosts.includes(host));
+
+ return {
+ finalHosts,
+ addedHosts,
+ deletedHosts,
+ isDeleteOnly: !addedHosts.length && deletedHosts.length > 0,
+ isNoOp: !addedHosts.length && !deletedHosts.length,
+ };
+}
+
+export function mergeSavedOperations<T extends Operation>(
+ operations: T[],
+ savedOperations: Array<Partial<T>> | undefined,
+): T[] {
+ if (!Array.isArray(savedOperations)) {
+ return operations;
+ }
+ return operations.map((operation) => {
+ const saved = savedOperations.find(
+ (item) =>
+ item.id === operation.id ||
+ (item.label && item.label === operation.label),
+ );
+ return saved
+ ? ({
+ ...operation,
+ ...saved,
+ id: operation.id,
+ callback: operation.callback,
+ } as T)
+ : operation;
+ });
+}
+
+export function buildDesiredConfigQuery(
+ desiredConfigs: Record<string, { tag?: string }> = {},
+ siteNames: string[],
+): string {
+ return [...new Set(siteNames)].map((siteName) => {
+ const tag = desiredConfigs[siteName]?.tag;
+ if (!tag) {
+ throw new Error(`The current ${siteName} configuration tag is missing.`);
+ }
+ return `(type=${siteName}&tag=${tag})`;
+ }).join("|");
+}
+
+export function mergeReviewedConfigs(
+ currentConfigs: { items?: ConfigSite[] },
+ reviewedConfigs: { items?: ConfigSite[] },
+ configsToRemove: Record<string, string[]> = {},
+): { items: ConfigSite[] } {
+ const reviewedItems = reviewedConfigs?.items || [];
+ const items = (currentConfigs?.items || []).map((currentItem) => {
+ const reviewedItem = reviewedItems.find(
+ (item) => item.type === currentItem.type,
+ );
+ const properties = {
+ ...(currentItem.properties || {}),
+ ...(reviewedItem?.properties || {}),
+ };
+ (configsToRemove[currentItem.type || ""] || []).forEach((propertyName) => {
+ delete properties[propertyName];
+ });
+ return {
+ ...currentItem,
+ properties,
+ };
+ });
+
+ return { ...currentConfigs, items };
+}
+
+export function updateReviewedConfigValue(
+ configData: { items?: ConfigSite[] },
+ siteName: string,
+ propertyName: string,
+ value: string,
+) {
+ let siteFound = false;
+ const items = (configData?.items || []).map((item) => {
+ if (item.type !== siteName) return item;
+ siteFound = true;
+ return {
+ ...item,
+ properties: {
+ ...(item.properties || {}),
+ [propertyName]: value,
+ },
+ };
+ });
+ if (!siteFound) {
+ throw new Error(`The reviewed ${siteName} configuration is missing.`);
+ }
+ return { ...configData, items };
+}
+
+export function getRangerReconfigureSiteGroups(
+ selectedServices: string[],
+ configItems: ConfigSite[],
+): string[][] {
+ const hasAuditProperty = (type: string) => {
+ const site = configItems.find((item) => item.type === type);
+ return Boolean(
+ site?.properties &&
+ Object.prototype.hasOwnProperty.call(
+ site.properties,
+ "xasecure.audit.destination.hdfs.dir",
+ ),
+ );
+ };
+ const present = (...types: string[]) => types.filter(hasAuditProperty);
+ const groups: string[][] = [["ranger-env"]];
+
+ if (selectedServices.includes("YARN")) {
+ groups.push(present("ranger-yarn-audit"));
+ }
+ if (selectedServices.includes("STORM")) {
+ groups.push(
+ present("ranger-storm-plugin-properties", "ranger-storm-audit"),
+ );
+ }
+ if (selectedServices.includes("KAFKA")) {
+ groups.push(present("ranger-kafka-audit"));
+ }
+ if (selectedServices.includes("KNOX")) {
+ groups.push(
+ present("ranger-knox-plugin-properties", "ranger-knox-audit"),
+ );
+ }
+ if (selectedServices.includes("ATLAS")) {
+ groups.push(present("ranger-atlas-audit"));
+ }
+ if (selectedServices.includes("HIVE")) {
+ groups.push(
+ present("ranger-hive-plugin-properties", "ranger-hive-audit"),
+ );
+ }
+ if (selectedServices.includes("RANGER_KMS")) {
+ groups.push(present("ranger-kms-audit"));
+ }
+
+ return groups.filter((group) => group.length > 0);
+}
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step1.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step1.tsx
index 6712488a2a..9615d4f549 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step1.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step1.tsx
@@ -18,21 +18,46 @@
import { useContext, useState } from "react";
import { AppContext } from "../../../../store/context";
-import { map } from "lodash";
+import { find, flatten, get, map } from "lodash";
import { ActionTypes } from "./store/types";
import WizardFooter from "../../../../components/StepWizard/WizardFooter";
import AssignMastersAddable from "../../../../components/AssignMastersAddable";
import { Card } from "react-bootstrap";
import { ManageJournalNodesContext } from "./store/context";
+import { ServiceContext } from "../../../../store/ServiceContext";
+import { getJournalNodeChangeSet } from "../haWorkflowUtils";
function Step1() {
- const { services } = useContext(AppContext);
+ const { services, allHostNames } = useContext(AppContext);
+ const { allServiceModels } = useContext(ServiceContext);
const {
dispatch,
flushStateToDb,
stepWizardUtilities: { handleNextImperitive, currentStep },
} = useContext(ManageJournalNodesContext);
- const [isNextEnabled] = useState(true);
+ const journalNodeComponent = find(
+ flatten([
+ allServiceModels?.hdfs?.masterComponents || [],
+ allServiceModels?.hdfs?.slaveComponents || [],
+ ]),
+ ["componentName", "JOURNALNODE"],
+ );
+ const originalJournalNodeHosts = map(
+ journalNodeComponent?.hostComponents ||
+ allServiceModels?.hdfs?.journalNodes ||
+ [],
+ (hostComponent: any) =>
+ get(hostComponent, "HostRoles.host_name", hostComponent.hostName),
+ ).filter(Boolean);
+ const existingCount = Math.max(
+ journalNodeComponent?.totalCount || 0,
+ originalJournalNodeHosts.length,
+ );
+ const maximumJournalNodes = Math.min(
+ allHostNames.length,
+ Math.max(3, existingCount * 2 - 1),
+ );
+ const [isNextEnabled, setIsNextEnabled] = useState(false);
return (
<>
<div className="step-title">Assign JournalNodes</div>
@@ -42,15 +67,25 @@ function Step1() {
<Card className="mt-2">
<Card.Body>
<AssignMastersAddable
- showJournalNode
+ showJournalNode
mastersToShow={["JOURNALNODE"]}
- mastersToAdd={["JOURNALNODE"]}
+ mastersToAdd={[]}
mastersToCreate={[]}
showCurrentPrefix={["JOURNALNODE"]}
showAdditionalPrefix={["JOURNALNODE"]}
services={map(services, "ServiceInfo.service_name")}
mastersAddableInHA={["JOURNALNODE"]}
+ maximumMasterCount={maximumJournalNodes}
dispatch={(payload:any) => {
+ const changeSet = getJournalNodeChangeSet(
+ payload.masterComponentHosts,
+ originalJournalNodeHosts,
+ );
+ setIsNextEnabled(
+ changeSet.finalHosts.length >= 3 &&
+ changeSet.finalHosts.length <= maximumJournalNodes &&
+ !changeSet.isNoOp,
+ );
dispatch({
type: ActionTypes.STORE_INFORMATION,
payload: {
@@ -58,7 +93,7 @@ function Step1() {
data: payload,
},
});
- flushStateToDb();
+ void flushStateToDb();
}}
/>
</Card.Body>
@@ -66,13 +101,13 @@ function Step1() {
<WizardFooter
step={currentStep}
isNextEnabled={isNextEnabled}
- onCancel={()=>{
- flushStateToDb("cancel");
+ onCancel={() => {
+ void flushStateToDb("cancel");
}}
onBack={() => {}}
- onNext={() => {
- flushStateToDb("next");
- handleNextImperitive();
+ onNext={async () => {
+ await flushStateToDb("next");
+ await handleNextImperitive();
}}
/>
</>
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step2.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step2.tsx
index ac4cac0a75..7838cc27af 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step2.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step2.tsx
@@ -30,13 +30,13 @@ import {
uniq,
} from "lodash";
import { getStepData } from "../../../../Utils/Utility";
-import NnHaConfigInitializer from "../../../../Utils/configs";
import { HostsApi } from "../../../../api/hostsApi";
import Spinner from "../../../../components/Spinner";
import {
Accordion,
Alert,
Badge,
+ Button,
Card,
Col,
Form,
@@ -53,6 +53,7 @@ import { ActionTypes } from "./store/types";
import useHDFSConfigsTags from "../../../../hooks/useConfigsTags";
import { ManageJournalNodesContext } from "./store/context";
import move_journal_node_properties from "../../../../data/configs/wizards/move_journal_node_properties";
+import { buildJournalNodeSharedEditsConfigs } from "../haWorkflowUtils";
//@ts-ignore
interface ConfigResponse {
Clusters: {
@@ -73,9 +74,12 @@ function Step2() {
currentStep,
handleNextImperitive,
handleBackImperitive,
+ setStepsHidden,
},
} = useContext(ManageJournalNodesContext);
- const moveJnConfig = move_journal_node_properties["moveJNConfig"];
+ const moveJnConfig: any = cloneDeep(
+ move_journal_node_properties["moveJNConfig"],
+ );
const { clusterName, services } = useContext(AppContext);
const serverConfigDataRef = useRef<any>([]);
const [clusterHostComponentsMapping, setClusterHostComponentsMapping] =
@@ -83,7 +87,13 @@ function Step2() {
const [stepConfigs, setStepConfigs] = useState<any>(null);
const [selectedService, setSelectedService] = useState({});
const [errors, setErrors] = useState<any>({});
- const { configsData } = useHDFSConfigsTags();
+ const {
+ configsData,
+ configsError,
+ isConfigsLoading,
+ reloadConfigs,
+ } = useHDFSConfigsTags();
+ const [configBuildError, setConfigBuildError] = useState("");
const [overridenProperties, setOverridenProperties] = useState({});
const nameserviceId = useRef("");
@@ -96,34 +106,14 @@ function Step2() {
const selectedServices = map(services, "ServiceInfo.service_name");
- function _prepareDependencies() {
- const ret: any = {};
- const configsFromServer = serverConfigDataRef.current.items;
- ret.namespaceId = nameserviceId.current;
- ret.serverConfigs = configsFromServer;
- return ret;
- }
-
- function tweakServiceConfigs(allConfigsDescriptor: any) {
- //TODO: check for namenode federation when implemented
- const configs = filter(allConfigsDescriptor, [
- "presentForNonFederatedHDFS",
- true,
- ]);
- const nameSpaceDependentConfigs = filter(configs, [
- "dependsOnNameServiceId",
- true,
- ]);
- console.log("NameSpace dependent configs", nameSpaceDependentConfigs);
- const nameSpaceIndependentConfigs = filter(configs, [
- "dependsOnNameServiceId",
- false,
- ]);
- const localDB = getStepData(
- state,
- "ASSIGN_JOURNALNODES",
- "",
- "manageJournalNodesSteps"
+ function tweakServiceConfigs() {
+ const localDB = cloneDeep(
+ getStepData(
+ state,
+ "ASSIGN_JOURNALNODES",
+ "",
+ "manageJournalNodesSteps",
+ ),
);
const allHostComponents = flatten(
map(clusterHostComponentsMapping, "host_components")
@@ -154,17 +144,22 @@ function Step2() {
masterComponent.component = masterComponent.component_name;
}
}
- const commonDependencies = _prepareDependencies();
- const generatedConfigs: any = [];
- nameSpaceIndependentConfigs.forEach(function (config: any) {
- NnHaConfigInitializer.prototype.initialValue(
- config,
- localDB,
- commonDependencies
- );
- config.isOverridable = false;
- generatedConfigs.push(config);
- });
+ const nameservices = nameserviceId.current
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean);
+ const journalNodeHosts = localDB.masterComponentHosts
+ .filter(
+ (item: any) =>
+ item.component === "JOURNALNODE" ||
+ item.component_name === "JOURNALNODE",
+ )
+ .map((item: any) => item.hostName || item.selectedHost);
+ const generatedConfigs = buildJournalNodeSharedEditsConfigs(
+ nameservices,
+ journalNodeHosts,
+ nameservices.length > 1,
+ );
moveJnConfig.configs = generatedConfigs;
return generatedConfigs;
}
@@ -198,6 +193,7 @@ function Step2() {
setSelectedService(serviceConfig?.configCategories?.[0]?.name);
}
async function loadConfigsTags() {
+ setConfigBuildError("");
try {
serverConfigDataRef.current = configsData;
const nameservices = configsData?.items?.find((item: any) =>
@@ -206,7 +202,7 @@ function Step2() {
if (nameservices) {
nameserviceId.current = nameservices?.properties["dfs.nameservices"];
}
- tweakServiceConfigs(moveJnConfig.configs);
+ tweakServiceConfigs();
const overridenPropertiesCopy = cloneDeep(configsData);
for (const siteConfig of moveJnConfig.configs) {
const site = get(siteConfig, "filename", "");
@@ -228,6 +224,11 @@ function Step2() {
renderServiceConfigs(moveJnConfig);
} catch (configErr) {
console.error("Error loading config tags", configErr);
+ setConfigBuildError(
+ configErr instanceof Error
+ ? configErr.message
+ : "Ambari could not prepare the JournalNode configuration review.",
+ );
}
}
useEffect(() => {
@@ -241,16 +242,26 @@ function Step2() {
}, [clusterHostComponentsMapping, configsData]);
useEffect(() => {
async function makeApiCalls() {
- await getClusterComponents();
+ try {
+ await getClusterComponents();
+ } catch (error: any) {
+ setConfigBuildError(
+ error?.response?.data?.message ||
+ error?.message ||
+ "Ambari could not load the cluster component topology.",
+ );
+ }
}
makeApiCalls();
}, []);
function getMastersInfo() {
- const step1Data = getStepData(
- state,
- "ASSIGN_JOURNALNODES",
- "masterComponentHosts",
- "manageJournalNodesSteps"
+ const step1Data = cloneDeep(
+ getStepData(
+ state,
+ "ASSIGN_JOURNALNODES",
+ "masterComponentHosts",
+ "manageJournalNodesSteps",
+ ),
);
if (!step1Data || step1Data.length === 0) {
@@ -446,7 +457,46 @@ function Step2() {
useEffect(() => {
validateForErrors();
}, [stepConfigs]);
- if (!stepConfigs || isEmpty(overridenProperties)) {
+ const reviewError = configsError || configBuildError;
+ if (reviewError) {
+ return (
+ <>
+ <div className="step-title">Review</div>
+ <Alert variant="danger" className="mt-3">
+ {reviewError}
+ <Button
+ className="ms-3"
+ size="sm"
+ onClick={() => {
+ setConfigBuildError("");
+ setStepConfigs(null);
+ void reloadConfigs();
+ void getClusterComponents().catch((error: any) => {
+ setConfigBuildError(
+ error?.response?.data?.message ||
+ error?.message ||
+ "Ambari could not load the cluster component topology.",
+ );
+ });
+ }}
+ >
+ Retry
+ </Button>
+ </Alert>
+ <WizardFooter
+ step={currentStep}
+ isNextEnabled={false}
+ onNext={() => undefined}
+ onBack={async () => {
+ await flushStateToDb("back");
+ await handleBackImperitive();
+ }}
+ onCancel={() => void flushStateToDb("cancel")}
+ />
+ </>
+ );
+ }
+ if (isConfigsLoading || !stepConfigs || isEmpty(overridenProperties)) {
return <Spinner />;
}
return (
@@ -593,20 +643,19 @@ function Step2() {
Object.keys(errors)?.filter((error) => errors[error].message !== "")
.length === 0)
}
- onBack={() => {
- flushStateToDb("back");
- handleBackImperitive();
+ onBack={async () => {
+ await flushStateToDb("back");
+ await handleBackImperitive();
}}
onCancel={() => {
- flushStateToDb("cancel");
+ void flushStateToDb("cancel");
}}
- onNext={() => {
- const step1Data = getStepData(
- state,
- "ASSIGN_JOURNALNODES",
- "masterComponentHosts",
- "manageJournalNodesSteps"
- );
+ onNext={async () => {
+ const mastersInfo = getMastersInfo();
+ const isDeleteOnly =
+ mastersInfo.additionalJournalNodes.length === 0 &&
+ mastersInfo.deletedJournalNodes.length > 0;
+ setStepsHidden([2, 4], isDeleteOnly);
dispatch({
type: ActionTypes.STORE_INFORMATION,
payload: {
@@ -614,21 +663,14 @@ function Step2() {
data: {
stepConfigs,
overridenProperties,
- deletedJournalNodes: getMastersInfo().deletedJournalNodes,
- addedJournalNodes: map(
- filter(
- step1Data,
- (jn: any) =>
- jn.component === "JOURNALNODE" ||
- jn.component_name === "JOURNALNODE"
- ),
- "hostName"
- ),
+ deletedJournalNodes: mastersInfo.deletedJournalNodes,
+ addedJournalNodes: mastersInfo.additionalJournalNodes,
+ isDeleteOnly,
},
},
});
- flushStateToDb("next");
- handleNextImperitive();
+ await flushStateToDb(isDeleteOnly ? "jump" : "next", 3);
+ await handleNextImperitive(isDeleteOnly ? 3 : undefined);
}}
/>
</>
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step3.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step3.tsx
index 50b60f782e..16c8d9ad47 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step3.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step3.tsx
@@ -17,137 +17,131 @@
*/
import { useContext, useEffect, useState } from "react";
-import { getStepData } from "../../../../Utils/Utility";
-import { filter, find, get, isEmpty, map } from "lodash";
+import { find, map } from "lodash";
import adminApi from "../../../../api/adminApi";
import { AppContext } from "../../../../store/context";
import WizardFooter from "../../../../components/StepWizard/WizardFooter";
-import { Card, ListGroup } from "react-bootstrap";
+import { Alert, Card, ListGroup } from "react-bootstrap";
import { ManageJournalNodesContext } from "./store/context";
import { ServiceContext } from "../../../../store/ServiceContext";
+import usePolling from "../../../../hooks/usePolling";
+import {
+ evaluateCheckpointSet,
+ evaluateNameNodeCheckpoint,
+ getHdfsNamespaces,
+ getHdfsUser,
+} from "../haWorkflowUtils";
+import ConfigsApi from "../../../../api/configsApi";
function Step3() {
const { clusterName } = useContext(AppContext);
const {
- state,
flushStateToDb,
stepWizardUtilities: { currentStep, handleNextImperitive,handleBackImperitive },
} = useContext(ManageJournalNodesContext);
- console.log("Step3 state", state);
const [isNextEnabled, setIsNextEnabled] = useState(false);
const { allServiceModels } = useContext(ServiceContext);
- const [, setIsActiveNameNodesStarted] =
- useState(false);
+ const [isActiveNameNodesStarted, setIsActiveNameNodesStarted] =
+ useState(true);
+ const [pollError, setPollError] = useState("");
+ const [hdfsUser, setHdfsUser] = useState("hdfs");
const hdfsModel = allServiceModels["hdfs"];
+ const { pausePolling } = usePolling(pullCheckPointsStatuses);
- function getNnCheckPointStatus(data: any) {
- const isInSafeMode = !isEmpty(get(data, "metrics.dfs.namenode.Safemode"));
- let journalTransactionInfo = JSON.parse(
- get(data, "metrics.dfs.namenode.JournalTransactionInfo")
+ useEffect(() => {
+ async function loadHdfsUser() {
+ try {
+ const configData = await ConfigsApi.getConfigValues(clusterName, "HDFS");
+ setHdfsUser(getHdfsUser(configData));
+ } catch {
+ setHdfsUser("hdfs");
+ }
+ }
+ if (clusterName) void loadHdfsUser();
+ }, [clusterName]);
+
+ function getNamespaceTargets() {
+ const namespaces = getHdfsNamespaces(hdfsModel);
+ const activeHosts = map(hdfsModel?.activeNameNodes, "hostName");
+ const hostComponents = map(
+ find(hdfsModel?.masterComponents, ["componentName", "NAMENODE"])
+ ?.hostComponents,
+ "HostRoles",
);
- // in case when transaction info absent or invalid return 2 which will return false in next `if` statement
- journalTransactionInfo = !!journalTransactionInfo
- ? parseInt(journalTransactionInfo.LastAppliedOrWrittenTxId) -
- parseInt(journalTransactionInfo.MostRecentCheckpointTxId)
- : 2;
- return journalTransactionInfo <= 1 && isInSafeMode;
+
+ return namespaces.map((namespace: any) => {
+ const hosts = namespace.hosts || [];
+ const activeHost = hosts.find((host: string) => activeHosts.includes(host));
+ const startedHost = hosts.find((host: string) =>
+ hostComponents.some(
+ (component: any) =>
+ component.host_name === host && component.state === "STARTED",
+ ),
+ );
+ return {
+ nameserviceId: namespace.name,
+ hostName: activeHost || startedHost || hosts[0],
+ };
+ });
}
async function pullCheckPointStatus() {
- const masterComponentHosts = getStepData(
- state,
- "ASSIGN_JOURNALNODES",
- "masterComponentHosts",
- "manageJournalNodesSteps"
- );
- const hostName = find(
- filter(masterComponentHosts, ["component", "NAMENODE"]),
- ["isInstalled", true]
- )?.hostName;
+ const hostName = getNamespaceTargets()[0]?.hostName;
+ if (!hostName) {
+ setPollError("No NameNode is available for checkpoint validation.");
+ return;
+ }
try {
const data = await adminApi.getNnCheckPointStatus(clusterName, hostName);
- // const isNamenodeStarted = data.HostRoles.desired_state === "STARTED";
- const shouldEnableNext = getNnCheckPointStatus(data);
- if (shouldEnableNext) {
- setIsNextEnabled(true);
- }
+ const evaluation = evaluateNameNodeCheckpoint(data);
+ setIsActiveNameNodesStarted(evaluation.started);
+ setPollError(evaluation.error || "");
+ setIsNextEnabled(evaluation.ready);
+ if (evaluation.ready) pausePolling();
} catch (err) {
console.error("Error in fetching checkpoint status", err);
+ setIsNextEnabled(false);
+ setPollError(
+ "Ambari could not read the NameNode checkpoint status. Polling will retry.",
+ );
}
}
function checkNnCheckPointsStatuses(data: any) {
- const items = data.items,
- isNextEnabledLocal =
- items.length && items.every((item: any) => getNnCheckPointStatus(item));
- setIsActiveNameNodesStarted(
- items.length &&
- items.every(
- (item: any) => get(item, "HostRoles.desired_state") === "STARTED"
- )
- );
- setIsNextEnabled(isNextEnabledLocal);
- if (!isNextEnabledLocal) {
- window.setTimeout(() => {
- pullCheckPointsStatuses();
- }, 2000);
- }
+ const items = data?.items || [],
+ expectedHosts = getNamespaceTargets().map((target: any) => target.hostName);
+ const evaluation = evaluateCheckpointSet(expectedHosts, items || []);
+ setIsActiveNameNodesStarted(evaluation.started);
+ setPollError(evaluation.error || "");
+ setIsNextEnabled(evaluation.ready);
+ if (evaluation.ready) pausePolling();
}
async function pullCheckPointsStatuses() {
- const nameSpaces = hdfsModel?.["namespaces"] || [];
- const nameSpaceCount = nameSpaces.length;
+ if (!hdfsModel?.isNamespaceLoaded) return;
+ const targets = getNamespaceTargets();
+ const nameSpaceCount = targets.length;
if (nameSpaceCount > 1) {
- let hostNames = map(hdfsModel?.activeNameNodes, "hostName");
- if (hostNames.length < nameSpaceCount) {
- nameSpaces.forEach((nameSpace: any) => {
- const { hosts } = nameSpace,
- hasActiveNameNode = hosts.some((hostName: any) =>
- hostNames.includes(hostName)
- );
- if (!hasActiveNameNode) {
- const hostForNameSpace =
- hosts.find((hostName: any) => {
- const hostComponents = map(
- find(hdfsModel?.masterComponents, [
- "componentName",
- "NAMENODE",
- ])?.hostComponents,
- "HostRoles"
- );
- return find(hostComponents, (hostComponent: any) => {
- return (
- hostComponent.host_name === hostName &&
- hostComponent.state === "STARTED"
- );
- });
- }) || hosts[0];
- hostNames.push(hostForNameSpace);
- }
- });
- }
+ const hostNames = targets.map((target: any) => target.hostName);
try {
- const data = await adminApi.getNnCheckPointStatus(
+ const data = await adminApi.getNnCheckPointStatuses(
clusterName,
hostNames.join(",")
);
- // const isNamenodeStarted = data.HostRoles.desired_state === "STARTED";
checkNnCheckPointsStatuses(data);
} catch (err) {
console.error("Error in fetching checkpoint status", err);
+ setIsNextEnabled(false);
+ setPollError(
+ "Ambari could not read all NameNode checkpoint statuses. Polling will retry.",
+ );
}
} else {
pullCheckPointStatus();
}
}
- function getNamenodeHost() {
- return hdfsModel?.activeNameNodes?.[0]?.hostName;
- }
- useEffect(() => {
- if (hdfsModel?.isNamespaceLoaded) {
- pullCheckPointsStatuses();
- }
- }, [JSON.stringify(hdfsModel)]);
+ const namespaceTargets = getNamespaceTargets();
+ const isFederated = namespaceTargets.length > 1;
return (
<>
@@ -159,22 +153,26 @@ function Step3() {
<Card.Body>
<ListGroup>
<ol>
- <li className="fs-12">
- Login to NameNode host{" "}
- <span className="fw-bolder fs-12">{getNamenodeHost()}</span>
- </li>
- <li className="mt-3 fs-12">
- Put the NameNode in Safe Mode (read only mode):
- <div className="code-snippet fs-12 mt-2">
- sudo su hdfs -l -c 'hdfs dfsadmin -safemode enter'
- </div>
- </li>
- <li className="mt-3 fs-12">
- Once in Safe Mode, create a Checkpoint:
- <div className="code-snippet mt-2">
- sudo su hdfs -l -c 'hdfs dfsadmin -saveNamespace'
- </div>
- </li>
+ {namespaceTargets.map((target: any) => (
+ <li className="fs-12 mb-3" key={target.nameserviceId}>
+ Login to NameNode host{" "}
+ <span className="fw-bolder fs-12">{target.hostName}</span>
+ <div className="code-snippet fs-12 mt-2">
+ sudo su {hdfsUser} -l -c 'hdfs dfsadmin
+ {isFederated
+ ? ` -fs hdfs://${target.nameserviceId}`
+ : ""}{" "}
+ -safemode enter'
+ </div>
+ <div className="code-snippet mt-2">
+ sudo su {hdfsUser} -l -c 'hdfs dfsadmin
+ {isFederated
+ ? ` -fs hdfs://${target.nameserviceId}`
+ : ""}{" "}
+ -saveNamespace'
+ </div>
+ </li>
+ ))}
<li className="mt-3 fs-12">
You will be able to proceed once Ambari detects that the
NameNode is in Safe Mode and the Checkpoint has been created
@@ -182,6 +180,16 @@ function Step3() {
</li>
</ol>
</ListGroup>
+ {!isActiveNameNodesStarted ? (
+ <Alert variant="danger" className="mt-3">
+ Every NameNode selected for checkpoint validation must be started.
+ </Alert>
+ ) : null}
+ {pollError ? (
+ <Alert variant="danger" className="mt-3">
+ {pollError}
+ </Alert>
+ ) : null}
{/* <Alert variant="warning" className="mt-4 fs-14">
If the <span className="fw-bold">Next</span> button is enabled
before you run the
@@ -195,19 +203,19 @@ function Step3() {
</Card>
</div>
<WizardFooter
- onBack={() => {
- flushStateToDb("back");
- handleBackImperitive();
+ onBack={async () => {
+ await flushStateToDb("back");
+ await handleBackImperitive();
}}
sideItems={isNextEnabled?"Checkpoint created":"Checkpoint not created yet"}
step={currentStep}
isNextEnabled={isNextEnabled}
- onNext={() => {
- flushStateToDb("next");
- handleNextImperitive();
+ onNext={async () => {
+ await flushStateToDb("next");
+ await handleNextImperitive();
}}
- onCancel={()=>{
- flushStateToDb("cancel");
+ onCancel={() => {
+ void flushStateToDb("cancel");
}}
/>
</>
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step4.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step4.tsx
index dd960ee755..3412b61d0e 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step4.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step4.tsx
@@ -35,6 +35,7 @@ import { ManageJournalNodesContext } from "./store/context";
import { ActionTypes } from "./store/types";
import { manageJournalNodesSteps } from "./wizardSteps";
import useKDCSessionState from "../../../../hooks/useKDCSessionState";
+import { mergeSavedOperations } from "../haWorkflowUtils";
function Step4() {
const { clusterName } = useContext(AppContext);
@@ -106,6 +107,7 @@ function Step4() {
);
} catch (err) {
console.error("Could not update configs", err);
+ throw err;
}
}
@@ -202,7 +204,8 @@ function Step4() {
clusterName,
"JOURNALNODE",
hostName,
- "HDFS"
+ "HDFS",
+ true,
);
});
@@ -230,14 +233,24 @@ function Step4() {
},
},
];
+ const savedOperationsState = getStepData(
+ state,
+ manageJournalNodesSteps.ADD_REMOVE_JOURNALNODES,
+ "operationsState",
+ "manageJournalNodesSteps",
+ );
+ const restoredOperations = mergeSavedOperations(
+ operations,
+ savedOperationsState,
+ );
return (
<>
<OperationsProgress
title=""
description=""
setCompletionStatus={setCompletionStatus}
- operations={operations as any}
- dispatch={(operationsState: any) => {
+ operations={restoredOperations as any}
+ dispatch={async (operationsState: any) => {
dispatch({
type: ActionTypes.STORE_INFORMATION,
payload: {
@@ -247,22 +260,24 @@ function Step4() {
},
},
});
+ await flushStateToDb();
}}
/>
<WizardFooter
step={currentStep}
isNextEnabled={completionStatus}
- onNext={() => {
- flushStateToDb("next");
- handleNextImperitive();
+ onNext={async () => {
+ await flushStateToDb("next");
+ await handleNextImperitive();
}}
onBack={() => {
flushStateToDb("back");
handleBackImperitive();
}}
onCancel={() => {
- flushStateToDb("cancel");
+ void flushStateToDb("cancel");
}}
+ cancelConfirmationBody="JournalNode changes have started. Exiting preserves the workflow checkpoint so the operation can be resumed. Any incomplete recovery must be performed manually before changing HDFS topology again."
/>
</>
);
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step5.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step5.tsx
index c63638e1a3..ad321199e5 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step5.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step5.tsx
@@ -19,18 +19,24 @@
import { useContext } from "react";
import { ServiceContext } from "../../../../store/ServiceContext";
import Spinner from "../../../../components/Spinner";
-import { Card } from "react-bootstrap";
+import { Alert, Button, Card } from "react-bootstrap";
import WizardFooter from "../../../../components/StepWizard/WizardFooter";
import { ManageJournalNodesContext } from "./store/context";
-import { cloneDeep, filter, find, get, map, sortBy, uniq } from "lodash";
+import { filter, find } from "lodash";
import { getStepData } from "../../../../Utils/Utility";
import useHDFSConfigsTags from "../../../../hooks/useConfigsTags";
+import { getJournalNodeDirectories } from "../haWorkflowUtils";
function Step5() {
const { allServiceModels } = useContext(ServiceContext);
const hdfsModel = allServiceModels["hdfs"];
const namespacesLoaded = hdfsModel?.isNamespaceLoaded;
- const { configsData } = useHDFSConfigsTags();
+ const {
+ configsData,
+ configsError,
+ isConfigsLoading,
+ reloadConfigs,
+ } = useHDFSConfigsTags();
const {
state,
flushStateToDb,
@@ -41,59 +47,29 @@ function Step5() {
},
} = useContext(ManageJournalNodesContext);
+ const step1Data = getStepData(
+ state,
+ "ASSIGN_JOURNALNODES",
+ "masterComponentHosts",
+ "manageJournalNodesSteps",
+ );
+ const currentJournalNodes = find(
+ filter(step1Data, ["component", "JOURNALNODE"]),
+ ["isInstalled", true],
+ )?.hostName;
+ const hdfsSiteConfigs = find(configsData?.items, ["type", "hdfs-site"]);
+ const directoryEvaluation = getJournalNodeDirectories(
+ hdfsModel,
+ hdfsSiteConfigs?.properties,
+ );
+ const directoryError = directoryEvaluation.missingProperties.length
+ ? `Ambari could not load the JournalNode directory configuration: ${directoryEvaluation.missingProperties.join(
+ ", ",
+ )}.`
+ : "";
+
function getBodyText() {
- const step1Data = getStepData(
- state,
- "ASSIGN_JOURNALNODES",
- "masterComponentHosts",
- "manageJournalNodesSteps"
- );
- const masterComponentGroups = (function () {
- let result: any = [];
- const componentsCopy = cloneDeep(get(hdfsModel, "masterComponents"));
- const allNameNodes = map(
- componentsCopy.find(
- (component: any) => component.componentName === "NAMENODE"
- )?.hostComponents,
- "HostRoles"
- );
- allNameNodes.forEach((nameNode: any) => {
- const nameSpace = nameNode.haNameSpace || "default";
- const hostName = nameNode.hostName;
- const clusterId = nameNode.clusterIdValue || "default";
- const existingNameSpace = find(result, ["name", nameSpace]);
- const currentNameSpace = existingNameSpace || {
- name: nameSpace,
- title: nameSpace,
- hosts: [],
- components: ["NAMENODE", "ZKFC"],
- clusterId,
- };
- if (!existingNameSpace) {
- result.push(currentNameSpace);
- }
- if (!currentNameSpace.hosts.includes(hostName)) {
- currentNameSpace.hosts.push(hostName);
- }
- });
- return sortBy(result, "name");
- })();
- const currentJournalNodes = find(
- filter(step1Data, ["component", "JOURNALNODE"]),
- ["isInstalled", true]
- )?.hostName;
- const nameSpaces = map(masterComponentGroups, "name");
- const hdfsSiteConfigs = find(configsData?.items, ["type", "hdfs-site"]);
- const configProperties = hdfsSiteConfigs ? hdfsSiteConfigs.properties : {};
- const directories =
- nameSpaces.length > 1
- ? uniq(
- nameSpaces.map(
- (ns) => configProperties[`dfs.journalnode.edits.dir.${ns}`]
- )
- )
- : [configProperties["dfs.journalnode.edits.dir"]];
- const directoriesString = directories.map((dir) => `${dir}`).join(", ");
+ const directoriesString = directoryEvaluation.directories.join(", ");
return (
<ul>
<li>
@@ -112,7 +88,18 @@ function Step5() {
);
}
- if (!namespacesLoaded) {
+ if (configsError || (!isConfigsLoading && directoryError)) {
+ return (
+ <Alert variant="danger">
+ {configsError || directoryError}
+ <Button className="ms-3" size="sm" onClick={() => void reloadConfigs()}>
+ Retry
+ </Button>
+ </Alert>
+ );
+ }
+
+ if (!namespacesLoaded || isConfigsLoading) {
return <Spinner />;
}
@@ -132,13 +119,13 @@ function Step5() {
handleBackImperitive();
}}
step={currentStep}
- isNextEnabled={hdfsModel.isNamespaceLoaded}
- onNext={() => {
- flushStateToDb("next");
- handleNextImperitive();
+ isNextEnabled={hdfsModel.isNamespaceLoaded && !directoryError}
+ onNext={async () => {
+ await flushStateToDb("next");
+ await handleNextImperitive();
}}
onCancel={() => {
- flushStateToDb("cancel");
+ void flushStateToDb("cancel");
}}
/>
</div>
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step6.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step6.tsx
index 42750301fb..e9f4f12c28 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step6.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step6.tsx
@@ -16,47 +16,78 @@
* limitations under the License.
*/
-import { useContext, useState } from "react";
+import { useContext, useEffect, useState } from "react";
import OperationsProgress from "../../../../components/OperationsProgress";
-import { filter, map } from "lodash";
+import { find, get, map } from "lodash";
import { ManageJournalNodesContext } from "./store/context";
import { ActionTypes } from "./store/types";
import { getStepData } from "../../../../Utils/Utility";
import { updateComponent } from "../../../../Utils/taskUtils";
import { AppContext } from "../../../../store/context";
import WizardFooter from "../../../../components/StepWizard/WizardFooter";
+import { HostsApi } from "../../../../api/hostsApi";
+import { manageJournalNodesSteps } from "./wizardSteps";
+import { mergeSavedOperations } from "../haWorkflowUtils";
+import { Alert, Button } from "react-bootstrap";
function Step6() {
- const { clusterName } = useContext(AppContext);
+ const { clusterName } = useContext(AppContext);
const [completionStatus, setCompletionStatus] = useState(false);
+ const [journalNodeHosts, setJournalNodeHosts] = useState<string[]>([]);
+ const [topologyError, setTopologyError] = useState("");
+ const [isLoadingTopology, setIsLoadingTopology] = useState(true);
const {
state,
dispatch,
flushStateToDb,
stepWizardUtilities: { currentStep, handleNextImperitive,handleBackImperitive },
} = useContext(ManageJournalNodesContext);
+ const loadFinalTopology = async () => {
+ setIsLoadingTopology(true);
+ setTopologyError("");
+ try {
+ const response = await HostsApi.getClusterComponents(
+ clusterName,
+ "ServiceComponentInfo/component_name,host_components/HostRoles/host_name&minimal_response=true",
+ );
+ const journalNodeComponent = find(response.items, [
+ "ServiceComponentInfo.component_name",
+ "JOURNALNODE",
+ ]);
+ const hosts = map(
+ journalNodeComponent?.host_components || [],
+ "HostRoles.host_name",
+ ).filter(Boolean);
+ if (hosts.length < 3) {
+ throw new Error(
+ "Ambari returned fewer than three JournalNodes after the topology change.",
+ );
+ }
+ setJournalNodeHosts(hosts);
+ } catch (error: any) {
+ setTopologyError(
+ get(error, "response.data.message", error?.message) ||
+ "Ambari could not reload the final JournalNode topology.",
+ );
+ } finally {
+ setIsLoadingTopology(false);
+ }
+ };
+
+ useEffect(() => {
+ void loadFinalTopology();
+ }, [clusterName]);
+
const operations = [
{
id: 1,
label: "Start JournalNodes",
skippable: false,
callback: async () => {
- const step1Data = getStepData(
- state,
- "ASSIGN_JOURNALNODES",
- "masterComponentHosts",
- "manageJournalNodesSteps"
- );
- const currentJournalNodes = map(filter(step1Data, [
- "component",
- "JOURNALNODE",
- ]),"hostName");
-
-
return await updateComponent(
clusterName,
"JOURNALNODE",
- currentJournalNodes.join(","),
+ journalNodeHosts,
"HDFS",
"Start",
1
@@ -64,6 +95,31 @@ function Step6() {
},
},
];
+ const savedOperationsState = getStepData(
+ state,
+ manageJournalNodesSteps.START_JOURNALNODES,
+ "operationsState",
+ "manageJournalNodesSteps",
+ );
+ const restoredOperations = mergeSavedOperations(
+ operations,
+ savedOperationsState,
+ );
+
+ if (isLoadingTopology) {
+ return <div>Loading final JournalNode topology...</div>;
+ }
+
+ if (topologyError) {
+ return (
+ <Alert variant="danger">
+ {topologyError}
+ <Button className="ms-3" size="sm" onClick={loadFinalTopology}>
+ Retry
+ </Button>
+ </Alert>
+ );
+ }
return (
<>
<h3 className="step-title">Perform Operations</h3>
@@ -72,8 +128,8 @@ function Step6() {
title=""
description=""
setCompletionStatus={setCompletionStatus}
- operations={operations as any}
- dispatch={(operationsState: any) => {
+ operations={restoredOperations as any}
+ dispatch={async (operationsState: any) => {
dispatch({
type: ActionTypes.STORE_INFORMATION,
payload: {
@@ -83,19 +139,21 @@ function Step6() {
},
},
});
+ await flushStateToDb();
}}
/>
</div>
<WizardFooter
step={currentStep}
isNextEnabled={completionStatus}
- onNext={() => {
- flushStateToDb("next");
- handleNextImperitive();
+ onNext={async () => {
+ await flushStateToDb("next");
+ await handleNextImperitive();
}}
onCancel={() => {
- flushStateToDb("cancel");
+ void flushStateToDb("cancel");
}}
+ cancelConfirmationBody="JournalNode changes have started. Exiting preserves the workflow checkpoint so the operation can be resumed. Any incomplete recovery must be performed manually before changing HDFS topology again."
onBack={() => {
flushStateToDb("back");
handleBackImperitive();
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step7.test.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step7.test.tsx
new file mode 100644
index 0000000000..215d69540b
--- /dev/null
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step7.test.tsx
@@ -0,0 +1,81 @@
+/**
+ * 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 { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { AppContext } from "../../../../store/context";
+import { ManageJournalNodesContext } from "./store/context";
+
+const mocks = vi.hoisted(() => ({ hide: vi.fn() }));
+
+vi.mock("../../../../store/ModalManager", () => ({
+ default: { show: vi.fn(), hide: mocks.hide },
+}));
+vi.mock("../../../../Utils/taskUtils", () => ({
+ startAllServices: vi.fn(),
+}));
+vi.mock("../../../../components/OperationsProgress", () => ({
+ default: ({ setCompletionStatus }: { setCompletionStatus: (value: boolean) => void }) => (
+ <button onClick={() => setCompletionStatus(true)}>Finish operations</button>
+ ),
+}));
+vi.mock("../../../../components/StepWizard/WizardFooter", () => ({
+ default: ({ onNext, isNextEnabled }: { onNext: () => void; isNextEnabled: boolean }) => (
+ <button disabled={!isNextEnabled} onClick={onNext}>Complete wizard</button>
+ ),
+}));
+
+import Step7 from "./Step7";
+
+describe("Manage JournalNodes completion", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ it("reports checkpoint clearing failures without navigating", async () => {
+ const flushStateToDb = vi.fn().mockRejectedValue(
+ new Error("persist unavailable"),
+ );
+ render(
+ <AppContext.Provider value={{ clusterName: "c1" } as never}>
+ <ManageJournalNodesContext.Provider
+ value={
+ {
+ state: { manageJournalNodesSteps: {} },
+ dispatch: vi.fn(),
+ flushStateToDb,
+ stepWizardUtilities: {
+ currentStep: { name: "START_ALL_SERVICES" },
+ handleBackImperitive: vi.fn(),
+ },
+ } as never
+ }
+ >
+ <Step7 />
+ </ManageJournalNodesContext.Provider>
+ </AppContext.Provider>,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Finish operations" }));
+ fireEvent.click(screen.getByRole("button", { name: "Complete wizard" }));
+
+ expect(await screen.findByText("persist unavailable")).toBeTruthy();
+ expect(mocks.hide).not.toHaveBeenCalled();
+ });
+});
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step7.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step7.tsx
index 1c9e58d47b..9c0ccb76b6 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step7.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/Step7.tsx
@@ -24,11 +24,18 @@ import { startAllServices } from "../../../../Utils/taskUtils";
import { AppContext } from "../../../../store/context";
import WizardFooter from "../../../../components/StepWizard/WizardFooter";
import modalManager from "../../../../store/ModalManager";
+import { getStepData } from "../../../../Utils/Utility";
+import { manageJournalNodesSteps } from "./wizardSteps";
+import { mergeSavedOperations } from "../haWorkflowUtils";
+import { Alert } from "react-bootstrap";
function Step7() {
const { clusterName } = useContext(AppContext);
const [completionStatus, setCompletionStatus] = useState(false);
+ const [isCompleting, setIsCompleting] = useState(false);
+ const [completionError, setCompletionError] = useState("");
const {
+ state,
dispatch,
flushStateToDb,
stepWizardUtilities: { currentStep, handleBackImperitive },
@@ -43,16 +50,47 @@ function Step7() {
},
},
];
+ const savedOperationsState = getStepData(
+ state,
+ manageJournalNodesSteps.START_ALL_SERVICES,
+ "operationsState",
+ "manageJournalNodesSteps",
+ );
+ const restoredOperations = mergeSavedOperations(
+ operations,
+ savedOperationsState,
+ );
+ const completeWizard = async () => {
+ setIsCompleting(true);
+ setCompletionError("");
+ try {
+ await flushStateToDb("complete");
+ modalManager.hide();
+ window.location.href = "/#/main/services/HDFS/summary";
+ } catch (error: unknown) {
+ const persistenceError = error as {
+ message?: string;
+ response?: { data?: { message?: string } };
+ };
+ setCompletionError(
+ persistenceError.response?.data?.message ||
+ persistenceError.message ||
+ "Ambari could not clear the Manage JournalNodes workflow checkpoint.",
+ );
+ setIsCompleting(false);
+ }
+ };
return (
<>
+ {completionError ? <Alert variant="danger">{completionError}</Alert> : null}
<h3 className="step-title">Start services</h3>
<div className="mt-3">
<OperationsProgress
title=""
description=""
setCompletionStatus={setCompletionStatus}
- operations={operations as any}
- dispatch={(operationsState: any) => {
+ operations={restoredOperations as any}
+ dispatch={async (operationsState: any) => {
dispatch({
type: ActionTypes.STORE_INFORMATION,
payload: {
@@ -62,21 +100,22 @@ function Step7() {
},
},
});
+ await flushStateToDb();
}}
/>
</div>
<WizardFooter
step={currentStep}
- isNextEnabled={completionStatus}
- onNext={() => {
- flushStateToDb("cancel"); // Clear persisted data on completion
- modalManager.hide();
- window.location.href = "/#/main/services/HDFS/summary";
- }}
+ isNextEnabled={completionStatus && !isCompleting}
+ onNext={() => void completeWizard()}
onBack={() => {
flushStateToDb("back");
handleBackImperitive();
}}
+ onCancel={() => {
+ void flushStateToDb("cancel");
+ }}
+ cancelConfirmationBody="JournalNode changes have started. Exiting preserves the workflow checkpoint so the operation can be resumed. Any incomplete recovery must be performed manually before changing HDFS topology again."
/>
</>
);
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/ValidateEnablement.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/ValidateEnablement.tsx
index 0cd6997e14..370f5767fb 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/ValidateEnablement.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/ValidateEnablement.tsx
@@ -18,7 +18,7 @@
import { useContext, useEffect, useState } from "react";
import Modal from "../../../../components/Modal";
-import { filter, find } from "lodash";
+import { filter, find, flatten } from "lodash";
import Spinner from "../../../../components/Spinner";
import useStepWizard from "../../../../hooks/useStepWizard";
import wizardSteps from "./wizardSteps";
@@ -27,9 +27,11 @@ import ClusterApi from "../../../../api/clusterApi";
import { LocalStorageOps } from "../../../../Utils/LocalStorageOps";
import { ManageJournalNodesProvider } from "./store/context";
import { ServiceContext } from "../../../../store/ServiceContext";
+import { AppContext } from "../../../../store/context";
function ValidateEnablement() {
const { allServiceModels } = useContext(ServiceContext);
+ const { allHostNames, supports } = useContext(AppContext);
const stepWizardUtilities = useStepWizard(wizardSteps, 0, () => {
window.location.href = "/#/main/services/HDFS/summary";
});
@@ -69,7 +71,9 @@ function ValidateEnablement() {
const validateCanEnable = () => {
const messages: string[] = [];
const hdfsModel = allServiceModels?.["hdfs"];
- console.log("HDFS model is", allServiceModels?.["hdfs"]);
+ if (!supports.manageJournalNode) {
+ messages.push("This stack does not support Manage JournalNodes.");
+ }
const namenodes: any = find(hdfsModel?.masterComponents, [
"componentName",
@@ -80,7 +84,6 @@ function ValidateEnablement() {
"haStatus",
"active",
]);
- console.log("Active Namenodes", activeNamenodes, namenodes);
const standbyNamenodes = filter(namenodes?.hostComponents, [
"haStatus",
"standby",
@@ -94,6 +97,26 @@ function ValidateEnablement() {
messages.push("Namenodes not present in HDFS service");
}
+ const journalNodeComponent = find(
+ flatten([
+ hdfsModel?.masterComponents || [],
+ hdfsModel?.slaveComponents || [],
+ ]),
+ ["componentName", "JOURNALNODE"],
+ );
+ const journalNodeCount = Math.max(
+ journalNodeComponent?.totalCount || 0,
+ hdfsModel?.journalNodes?.length || 0,
+ );
+ if (
+ journalNodeCount < 3 ||
+ !(allHostNames.length > journalNodeCount || journalNodeCount > 3)
+ ) {
+ messages.push(
+ "The current host and JournalNode counts do not allow an add or delete operation.",
+ );
+ }
+
if (!messages.length) {
setCanStartEnablement(true);
setValidationErrors([]);
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/store/context.test.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/store/context.test.tsx
new file mode 100644
index 0000000000..4c28f2448e
--- /dev/null
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/store/context.test.tsx
@@ -0,0 +1,120 @@
+/**
+ * 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 { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { useContext } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ getPersistData: vi.fn(),
+ postPersistData: vi.fn(),
+}));
+
+vi.mock("../../../../../api/clusterApi", () => ({
+ default: {
+ getPersistData: mocks.getPersistData,
+ postPersistData: mocks.postPersistData,
+ },
+}));
+vi.mock("../../../../../hooks/useAuth", () => ({
+ default: () => ({ user: { user_name: "jn-owner" } }),
+}));
+
+import {
+ ManageJournalNodesContext,
+ ManageJournalNodesProvider,
+} from "./context";
+
+const jumpToStep = vi.fn();
+const setStepsHidden = vi.fn();
+const wizardUtilities = {
+ activeStep: 0,
+ jumpToStep,
+ setStepsHidden,
+ wizardSteps: {
+ 0: { name: "ASSIGN_JOURNALNODES" },
+ 2: { name: "SAVE_NAMESPACE" },
+ 3: { name: "ADD_REMOVE_JOURNALNODES" },
+ },
+};
+
+function StateProbe() {
+ const { state, flushStateToDb } = useContext(ManageJournalNodesContext);
+ return (
+ <>
+ <div data-testid="state">{JSON.stringify(state)}</div>
+ <button onClick={() => void flushStateToDb()}>Persist</button>
+ </>
+ );
+}
+
+function renderProvider() {
+ return render(
+ <ManageJournalNodesProvider stepWizardUtilities={wizardUtilities}>
+ <StateProbe />
+ </ManageJournalNodesProvider>,
+ );
+}
+
+describe("Manage JournalNodes workflow hydration", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.postPersistData.mockResolvedValue({});
+ });
+
+ afterEach(() => cleanup());
+
+ it("treats a missing persisted value as a fresh workflow", async () => {
+ mocks.getPersistData.mockRejectedValue({ status: 404 });
+ renderProvider();
+
+ expect(await screen.findByTestId("state")).toBeTruthy();
+ expect(setStepsHidden).toHaveBeenCalledWith([2, 4], false);
+ expect(jumpToStep).toHaveBeenCalledWith(0, true);
+ expect(mocks.postPersistData).not.toHaveBeenCalled();
+ });
+
+ it("restores delete-only hidden steps from a string-valued checkpoint", async () => {
+ mocks.getPersistData.mockResolvedValue(
+ JSON.stringify({
+ activeStep: "SAVE_NAMESPACE",
+ manageJournalNodesSteps: {
+ REVIEW: { step: "REVIEW", data: { isDeleteOnly: true } },
+ },
+ }),
+ );
+ renderProvider();
+
+ await waitFor(() => expect(jumpToStep).toHaveBeenCalledWith(3, true));
+ expect(setStepsHidden).toHaveBeenCalledWith([2, 4], true);
+ expect(screen.getByTestId("state").textContent).toContain("isDeleteOnly");
+ expect(mocks.postPersistData).not.toHaveBeenCalled();
+ });
+
+ it("persists the workflow owner with the recoverable checkpoint", async () => {
+ mocks.getPersistData.mockRejectedValue({ status: 404 });
+ renderProvider();
+
+ fireEvent.click(await screen.findByRole("button", { name: "Persist" }));
+
+ await waitFor(() => expect(mocks.postPersistData).toHaveBeenCalledOnce());
+ expect(mocks.postPersistData.mock.calls[0][0]["wizard-data"]).toBe(
+ JSON.stringify({ userName: "jn-owner" }),
+ );
+ });
+});
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/store/context.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/store/context.tsx
index 17ea66f8ef..ee0ba0c7db 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/store/context.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/journalNode/store/context.tsx
@@ -24,12 +24,19 @@ import React, {
useRef,
useState,
} from "react";
+import { Alert, Button } from "react-bootstrap";
+import { get, isEmpty } from "lodash";
import { State, Action, ActionTypes } from "./types";
import { reducer, initialState } from "./reducer";
import ClusterApi from "../../../../../api/clusterApi";
-import { get, isEmpty } from "lodash";
import { ClusterProgressStatus } from "../../../../../constants";
import modalManager from "../../../../../store/ModalManager";
+import Spinner from "../../../../../components/Spinner";
+import {
+ parsePersistedValue,
+ persistedPayload,
+} from "../../../../../Utils/persistedSettings";
+import useAuth from "../../../../../hooks/useAuth";
interface ManageJournalNodesContextProps {
state: State;
@@ -49,132 +56,186 @@ export const ManageJournalNodesProvider: React.FC<{
stepWizardUtilities: any;
children: React.ReactNode;
}> = ({ stepWizardUtilities, children }) => {
- const [state, dispatch] = useReducer(reducer, initialState);
- const [currStepData, setCurrStepData] = useState({});
+ const { user } = useAuth();
+ const workflowOwner = user?.user_name || "";
+ const [state, reducerDispatch] = useReducer(reducer, initialState);
+ const [isHydrated, setIsHydrated] = useState(false);
+ const [initializationError, setInitializationError] = useState("");
+ const [retryCount, setRetryCount] = useState(0);
+ const stateRef = useRef<State>(initialState);
+ const currStepDataRef = useRef<Record<string, any>>({});
+ const persistenceQueue = useRef<Promise<void>>(Promise.resolve());
- const isDataPersisted = useRef(false);
+ const dispatch: Dispatch<Action> = (action) => {
+ stateRef.current = reducer(stateRef.current, action);
+ reducerDispatch(action);
+ };
- useEffect(() => {
- syncUserPersistedData();
- }, []);
+ const queuePersistence = (operation: () => Promise<void>) => {
+ const nextOperation = persistenceQueue.current
+ .catch(() => undefined)
+ .then(operation);
+ persistenceQueue.current = nextOperation.catch(() => undefined);
+ return nextOperation;
+ };
useEffect(() => {
- if (isDataPersisted.current) {
- flushCurrentData();
- }
- }, [state.manageJournalNodesSteps, currStepData]);
+ void syncUserPersistedData();
+ }, [retryCount]);
async function syncUserPersistedData() {
+ setIsHydrated(false);
+ setInitializationError("");
try {
- const persistedData = await ClusterApi.getPersistData(
- "MANAGE_JOURNALNODES"
- );
+ let response: unknown = initialState;
+ try {
+ response = await ClusterApi.getPersistData("MANAGE_JOURNALNODES");
+ } catch (error: any) {
+ if (error?.response?.status !== 404 && error?.status !== 404) {
+ throw error;
+ }
+ }
+ const persistedData = parsePersistedValue(response, initialState);
if (!isEmpty(get(persistedData, "manageJournalNodesSteps", {}))) {
- dispatch({
- type: ActionTypes.SYNC_STATE,
- payload: persistedData,
- });
+ dispatch({ type: ActionTypes.SYNC_STATE, payload: persistedData });
}
- if (get(persistedData, "activeStep", "")) {
- try {
- const activeStepName = get(persistedData, "activeStep");
- setCurrStepData({
- progressStatus: ClusterProgressStatus.MANAGING_JOURNALNODES,
- stepName: activeStepName,
- });
- let activeStepNumber = Object.keys(
- stepWizardUtilities.wizardSteps
- ).find((stepName) => {
- return (
- stepWizardUtilities.wizardSteps?.[stepName]?.name ===
- activeStepName
- );
- });
- stepWizardUtilities.jumpToStep(Number(activeStepNumber), true);
- } catch (err) {
- console.error("Error while jumping to step", err);
+ const isDeleteOnly = get(
+ persistedData,
+ "manageJournalNodesSteps.REVIEW.data.isDeleteOnly",
+ false,
+ );
+ stepWizardUtilities.setStepsHidden([2, 4], isDeleteOnly);
+ const activeStepName = get(persistedData, "activeStep", "");
+ if (activeStepName) {
+ const restoredStepData = {
+ progressStatus: ClusterProgressStatus.MANAGING_JOURNALNODES,
+ stepName: activeStepName,
+ };
+ currStepDataRef.current = restoredStepData;
+ const activeStepNumber = Object.keys(
+ stepWizardUtilities.wizardSteps,
+ ).find(
+ (stepName) =>
+ stepWizardUtilities.wizardSteps[stepName]?.name === activeStepName,
+ );
+ if (activeStepNumber !== undefined) {
+ let restoredStep = Number(activeStepNumber);
+ if (isDeleteOnly && restoredStep === 2) restoredStep = 3;
+ if (isDeleteOnly && restoredStep === 4) restoredStep = 5;
+ stepWizardUtilities.jumpToStep(restoredStep, true);
}
} else {
stepWizardUtilities.jumpToStep(0, true);
}
- } finally {
- isDataPersisted.current = true;
+ setIsHydrated(true);
+ } catch (error: any) {
+ setInitializationError(
+ error?.response?.data?.message ||
+ "Ambari could not restore the Manage JournalNodes workflow.",
+ );
}
}
- async function flushCurrentData() {
+ async function flushCurrentData(
+ stateSnapshot: State = stateRef.current,
+ stepSnapshot: Record<string, any> = currStepDataRef.current,
+ ) {
await ClusterApi.postPersistData(
- JSON.stringify({
+ persistedPayload({
MANAGE_JOURNALNODES: {
- ...state,
- activeStep: get(currStepData, "stepName", ""),
+ ...stateSnapshot,
+ activeStep: get(stepSnapshot, "stepName", ""),
},
- CLUSTER_STATE: currStepData,
- })
+ CLUSTER_STATE: stepSnapshot,
+ "wizard-data": { userName: workflowOwner },
+ }),
);
}
- function flushOnCancel() {
- ClusterApi.postPersistData(
- JSON.stringify({
- MANAGE_JOURNALNODES: initialState,
- CLUSTER_STATE: {},
- })
+ async function clearPersistedState() {
+ await queuePersistence(() =>
+ ClusterApi.postPersistData(
+ persistedPayload({
+ MANAGE_JOURNALNODES: initialState,
+ CLUSTER_STATE: {},
+ "wizard-data": {},
+ }),
+ ),
);
+ }
+
+ async function flushOnCancel() {
+ if (stepWizardUtilities.activeStep >= 3) {
+ await queuePersistence(() => flushCurrentData());
+ } else {
+ await clearPersistedState();
+ }
modalManager.hide();
window.location.href = "/#/main/services/HDFS/summary";
}
- async function flushOnStepChange(nextStep: number) {
- if (nextStep >= 0) {
- let nextStepDetails = stepWizardUtilities.wizardSteps?.[nextStep];
- if (nextStepDetails?.keysToRemove) {
- nextStepDetails.keysToRemove.forEach((key: string) => {
- if (state?.manageJournalNodesSteps?.[key]) {
- dispatch({
- type: ActionTypes.REMOVE_KEY,
- payload: { key },
- });
- }
- });
- }
- setCurrStepData({
- progressStatus: ClusterProgressStatus.MANAGING_JOURNALNODES,
- stepName: stepWizardUtilities?.wizardSteps?.[nextStep]?.name,
+ async function flushOnStepChange(nextStep: number | undefined) {
+ if (nextStep === undefined || nextStep < 0) return;
+ const nextStepDetails = stepWizardUtilities.wizardSteps[nextStep];
+ let nextState = stateRef.current;
+ nextStepDetails?.keysToRemove?.forEach((key: string) => {
+ nextState = reducer(nextState, {
+ type: ActionTypes.REMOVE_KEY,
+ payload: { key },
});
+ });
+ if (nextState !== stateRef.current) {
+ dispatch({ type: ActionTypes.SYNC_STATE, payload: nextState });
}
+ const nextStepData = {
+ progressStatus: ClusterProgressStatus.MANAGING_JOURNALNODES,
+ stepName: nextStepDetails?.name,
+ };
+ currStepDataRef.current = nextStepData;
+ await queuePersistence(() => flushCurrentData(nextState, nextStepData));
}
- function flushStateToDb(
+ async function flushStateToDb(
operation: string = "default",
- jumpStep: number = -1
+ jumpStep: number = -1,
) {
- let activeStep = Object.keys(stepWizardUtilities.wizardSteps).find(
- (stepName) => {
- return (
- stepWizardUtilities.wizardSteps?.[stepName]?.name ===
- stepWizardUtilities.currentStep.name
- );
- }
- );
switch (operation) {
case "cancel":
- flushOnCancel();
+ await flushOnCancel();
+ break;
+ case "complete":
+ await clearPersistedState();
break;
case "back":
- flushOnStepChange(Number(activeStep) - 1);
+ await flushOnStepChange(stepWizardUtilities.prevStepNumber);
break;
case "next":
- flushOnStepChange(Number(activeStep) + 1);
+ await flushOnStepChange(stepWizardUtilities.nextStepNumber);
break;
case "jump":
- flushOnStepChange(jumpStep);
+ await flushOnStepChange(jumpStep);
break;
default:
- flushCurrentData();
+ await queuePersistence(() => flushCurrentData());
}
}
+ if (initializationError) {
+ return (
+ <Alert variant="danger">
+ {initializationError}
+ <Button
+ size="sm"
+ className="ms-3"
+ onClick={() => setRetryCount((value) => value + 1)}
+ >
+ Retry
+ </Button>
+ </Alert>
+ );
+ }
+ if (!isHydrated) return <Spinner />;
+
return (
<ManageJournalNodesContext.Provider
value={{ state, dispatch, stepWizardUtilities, flushStateToDb }}
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step1.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step1.tsx
index a4b96f1b4a..79e3d356d3 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step1.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step1.tsx
@@ -34,16 +34,47 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMultiply } from "@fortawesome/free-solid-svg-icons";
import classNames from "classnames";
import { ActionTypes } from "./store/types";
+import { AppContext } from "../../../../store/context";
+import ConfigsApi from "../../../../api/configsApi";
+import { getStepData } from "../../../../Utils/Utility";
+import { getHdfsUser } from "../haWorkflowUtils";
function Step1() {
const [isNextEnabled, setIsNextEnabled] = useState(false);
const [nameserviceId, setNameserviceId] = useState("");
const {
+ state,
dispatch,
stepWizardUtilities: { currentStep, handleNextImperitive, jumpToStep },
flushStateToDb
} = useContext(EnableHighAvailibilityContext);
+ const { clusterName, services } = useContext(AppContext);
+ const [hdfsUser, setHdfsUser] = useState("hdfs");
const [nameError, setNameError] = useState("");
+ const isHawqInstalled = services.some(
+ (service: any) => service?.ServiceInfo?.service_name === "HAWQ",
+ );
+ useEffect(() => {
+ const savedData = getStepData(
+ state,
+ "GET_STARTED",
+ "",
+ "enableHighAvailibilitySteps",
+ );
+ if (savedData?.nameserviceId) setNameserviceId(savedData.nameserviceId);
+ if (savedData?.hdfsUser) setHdfsUser(savedData.hdfsUser);
+ }, [state]);
+ useEffect(() => {
+ async function loadHdfsUser() {
+ try {
+ const configData = await ConfigsApi.getConfigValues(clusterName, "HDFS");
+ setHdfsUser(getHdfsUser(configData));
+ } catch {
+ setHdfsUser((currentUser) => currentUser || "hdfs");
+ }
+ }
+ if (clusterName) void loadHdfsUser();
+ }, [clusterName]);
useEffect(() => {
if (nameserviceId) {
let nameSarviceIdRegex =
@@ -81,6 +112,11 @@ function Step1() {
</p>
<Alert className="mt-2" variant="warning">
If you have HBase running, please exit this wizard and stop HBase first
+ {isHawqInstalled ? (
+ <div className="mt-2">
+ HAWQ filespace must be updated manually after NameNode HA is enabled.
+ </div>
+ ) : null}
</Alert>
<Card className="mt-2">
<CardBody>
@@ -138,21 +174,22 @@ function Step1() {
flushStateToDb("back");
jumpToStep(0);
}}
- onNext={() => {
+ onNext={async () => {
dispatch({
type: ActionTypes.STORE_INFORMATION,
payload: {
step: currentStep.name,
data: {
nameserviceId,
+ hdfsUser,
},
},
});
- flushStateToDb("next");
- handleNextImperitive();
+ await flushStateToDb("next");
+ await handleNextImperitive();
}}
onCancel={() => {
- flushStateToDb("cancel");
+ void flushStateToDb("cancel");
}}
/>
</>
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step2.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step2.tsx
index 28d8ae6ded..1ea86a61f3 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step2.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step2.tsx
@@ -24,15 +24,20 @@ import { ActionTypes } from "./store/types";
import WizardFooter from "../../../../components/StepWizard/WizardFooter";
import AssignMastersAddable from "../../../../components/AssignMastersAddable";
import { Card } from "react-bootstrap";
+import { isValidNameNodeHaAssignment } from "../haWorkflowUtils";
function Step2() {
const { services } = useContext(AppContext);
const {
dispatch,
flushStateToDb,
- stepWizardUtilities: { handleNextImperitive, currentStep, jumpToStep },
+ stepWizardUtilities: {
+ handleNextImperitive,
+ handleBackImperitive,
+ currentStep,
+ },
} = useContext(EnableHighAvailibilityContext);
- const [isNextEnabled] = useState(true);
+ const [isNextEnabled, setIsNextEnabled] = useState(false);
return (
<>
<div className="step-title">Select Hosts</div>
@@ -58,7 +63,10 @@ function Step2() {
showAdditionalPrefix={["NAMENODE"]}
mastersAddableInHA={[]}
services={map(services, "ServiceInfo.service_name")}
- dispatch={(payload:any) => {
+ dispatch={(payload: any) => {
+ setIsNextEnabled(
+ isValidNameNodeHaAssignment(payload.masterComponentHosts),
+ );
dispatch({
type: ActionTypes.STORE_INFORMATION,
payload: {
@@ -73,16 +81,16 @@ function Step2() {
<WizardFooter
step={currentStep}
isNextEnabled={isNextEnabled}
- onBack={() => {
- flushStateToDb("back");
- jumpToStep(1);
+ onBack={async () => {
+ await flushStateToDb("back");
+ await handleBackImperitive();
}}
- onNext={() => {
- flushStateToDb("next");
- handleNextImperitive();
+ onNext={async () => {
+ await flushStateToDb("next");
+ await handleNextImperitive();
}}
onCancel={() => {
- flushStateToDb("cancel");
+ void flushStateToDb("cancel");
}}
/>
</>
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step3.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step3.tsx
index 7a0b7b2c26..712497f65f 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step3.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step3.tsx
@@ -40,6 +40,7 @@ import {
Accordion,
Alert,
Badge,
+ Button,
Card,
Col,
Form,
@@ -54,6 +55,7 @@ import classNames from "classnames";
import WizardFooter from "../../../../components/StepWizard/WizardFooter";
import { ActionTypes } from "./store/types";
import useHDFSConfigsTags from "../../../../hooks/useConfigsTags";
+import { updateReviewedConfigValue } from "../haWorkflowUtils";
//@ts-ignore
interface ConfigResponse {
Clusters: {
@@ -70,7 +72,11 @@ function Step3() {
state,
dispatch,
flushStateToDb,
- stepWizardUtilities: { currentStep, handleNextImperitive, jumpToStep },
+ stepWizardUtilities: {
+ currentStep,
+ handleNextImperitive,
+ handleBackImperitive,
+ },
} = useContext(EnableHighAvailibilityContext);
const { clusterName, services } = useContext(AppContext);
const serverConfigDataRef = useRef<any>([]);
@@ -79,9 +85,14 @@ function Step3() {
const [stepConfigs, setStepConfigs] = useState<any>(null);
const [selectedService, setSelectedService] = useState({});
const [errors, setErrors] = useState<any>({});
- const { configsData } = useHDFSConfigsTags();
+ const {
+ configsData,
+ configsError,
+ isConfigsLoading,
+ reloadConfigs,
+ } = useHDFSConfigsTags();
+ const [configBuildError, setConfigBuildError] = useState("");
const [overridenProperties, setOverridenProperties] = useState({});
- const configTagsLoaded = useRef(false);
const configsToRemove: any = {
"hdfs-site": [
"dfs.namenode.secondary.http-address",
@@ -131,11 +142,13 @@ function Step3() {
function tweakServiceConfigs(configs: any) {
console.log("Initial Configs", configs);
- const localDB = getStepData(
- state,
- "SELECT_HOSTS",
- "",
- "enableHighAvailibilitySteps"
+ const localDB = cloneDeep(
+ getStepData(
+ state,
+ "SELECT_HOSTS",
+ "",
+ "enableHighAvailibilitySteps",
+ ),
);
const allHostComponents = flatten(
map(clusterHostComponentsMapping, "host_components")
@@ -166,8 +179,6 @@ function Step3() {
masterComponent.component = masterComponent.component_name;
}
}
- console.log("Groupings is", groupings);
- console.log("local db is", localDB);
const dependencies = _prepareDependencies();
configs.forEach(function (config: any) {
NnHaConfigInitializer.prototype.initialValue(
@@ -177,7 +188,6 @@ function Step3() {
);
config.isOverridable = false;
});
- console.log("Final COnfigs", configs);
}
function removeConfigs(configs: any) {
Object.keys(configsToRemove).forEach(function (site) {
@@ -220,12 +230,14 @@ function Step3() {
setSelectedService(serviceConfig?.configCategories?.[0]?.name);
}
async function loadConfigsTags() {
+ setConfigBuildError("");
try {
- serverConfigDataRef.current = configsData;
- removeConfigs(configsData);
- tweakServiceConfigs(ha_properties.haConfig.configs);
- const overridenPropertiesCopy = cloneDeep(configsData);
- for (const siteConfig of ha_properties.haConfig.configs) {
+ const serverConfigs = removeConfigs(cloneDeep(configsData));
+ serverConfigDataRef.current = serverConfigs;
+ const haConfig = cloneDeep(ha_properties.haConfig);
+ tweakServiceConfigs(haConfig.configs);
+ const overridenPropertiesCopy = cloneDeep(serverConfigs);
+ for (const siteConfig of haConfig.configs) {
const site = get(siteConfig, "filename", "");
const correspondingSite = find(overridenPropertiesCopy.items, [
"type",
@@ -242,9 +254,14 @@ function Step3() {
}
}
setOverridenProperties(overridenPropertiesCopy);
- renderServiceConfigs(ha_properties.haConfig);
+ renderServiceConfigs(haConfig);
} catch (configErr) {
console.error("Error loading config tags", configErr);
+ setConfigBuildError(
+ configErr instanceof Error
+ ? configErr.message
+ : "Ambari could not prepare the NameNode HA configuration review.",
+ );
}
}
useEffect(() => {
@@ -253,22 +270,31 @@ function Step3() {
configsData &&
!isEmpty(configsData)
) {
- configTagsLoaded.current = true;
loadConfigsTags();
}
- }, [clusterHostComponentsMapping, configsData, overridenProperties]);
+ }, [clusterHostComponentsMapping, configsData]);
useEffect(() => {
async function makeApiCalls() {
- await getClusterComponents();
+ try {
+ await getClusterComponents();
+ } catch (error: any) {
+ setConfigBuildError(
+ error?.response?.data?.message ||
+ error?.message ||
+ "Ambari could not load the cluster component topology.",
+ );
+ }
}
makeApiCalls();
}, []);
function getMastersInfo() {
- const step2Data = getStepData(
- state,
- "SELECT_HOSTS",
- "masterComponentHosts",
- "enableHighAvailibilitySteps"
+ const step2Data = cloneDeep(
+ getStepData(
+ state,
+ "SELECT_HOSTS",
+ "masterComponentHosts",
+ "enableHighAvailibilitySteps",
+ ),
);
for (let masterComponent of step2Data) {
if (!has(masterComponent, "serviceId") || !masterComponent.serviceId) {
@@ -401,11 +427,12 @@ function Step3() {
const stepConfigsCopy: any = cloneDeep(stepConfigs);
const property = find(stepConfigsCopy.configs, ["name", propertyName]);
property.changedValue = value;
- const copiedProperties = cloneDeep(overridenProperties);
- const allProperties = flatten(
- map(get(copiedProperties, "items"), "properties")
+ const copiedProperties = updateReviewedConfigValue(
+ overridenProperties as Parameters<typeof updateReviewedConfigValue>[0],
+ property.filename,
+ propertyName,
+ value,
);
- allProperties[propertyName as any] = value;
setOverridenProperties(copiedProperties);
setStepConfigs(stepConfigsCopy);
}
@@ -448,7 +475,46 @@ function Step3() {
useEffect(() => {
validateForErrors();
}, [stepConfigs]);
- if (!stepConfigs || isEmpty(overridenProperties)) {
+ const reviewError = configsError || configBuildError;
+ if (reviewError) {
+ return (
+ <>
+ <div className="step-title">Review</div>
+ <Alert variant="danger" className="mt-3">
+ {reviewError}
+ <Button
+ className="ms-3"
+ size="sm"
+ onClick={() => {
+ setConfigBuildError("");
+ setStepConfigs(null);
+ void reloadConfigs();
+ void getClusterComponents().catch((error: any) => {
+ setConfigBuildError(
+ error?.response?.data?.message ||
+ error?.message ||
+ "Ambari could not load the cluster component topology.",
+ );
+ });
+ }}
+ >
+ Retry
+ </Button>
+ </Alert>
+ <WizardFooter
+ step={currentStep}
+ isNextEnabled={false}
+ onNext={() => undefined}
+ onBack={async () => {
+ await flushStateToDb("back");
+ await handleBackImperitive();
+ }}
+ onCancel={() => void flushStateToDb("cancel")}
+ />
+ </>
+ );
+ }
+ if (isConfigsLoading || !stepConfigs || isEmpty(overridenProperties)) {
return <Spinner />;
}
return (
@@ -608,11 +674,11 @@ function Step3() {
Object.keys(errors)?.filter((error) => errors[error].message !== "")
.length === 0)
}
- onBack={() => {
- flushStateToDb("back");
- jumpToStep(2);
+ onBack={async () => {
+ await flushStateToDb("back");
+ await handleBackImperitive();
}}
- onNext={() => {
+ onNext={async () => {
dispatch({
type: ActionTypes.STORE_INFORMATION,
payload: {
@@ -625,11 +691,11 @@ function Step3() {
},
},
});
- flushStateToDb("next");
- handleNextImperitive();
+ await flushStateToDb("next");
+ await handleNextImperitive();
}}
onCancel={() => {
- flushStateToDb("cancel");
+ void flushStateToDb("cancel");
}}
/>
</>
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step4.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step4.tsx
index 1e5e5fde54..35556727ff 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step4.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step4.tsx
@@ -19,13 +19,14 @@
import { useContext, useState } from "react";
import { EnableHighAvailibilityContext } from "./store/context";
import { getStepData } from "../../../../Utils/Utility";
-import { filter, find, get, isEmpty } from "lodash";
+import { filter, find } from "lodash";
import adminApi from "../../../../api/adminApi";
import { AppContext } from "../../../../store/context";
import usePolling from "../../../../hooks/usePolling";
import WizardFooter from "../../../../components/StepWizard/WizardFooter";
import { Alert, Card, ListGroup } from "react-bootstrap";
import useKDCSessionState from "../../../../hooks/useKDCSessionState";
+import { evaluateNameNodeCheckpoint } from "../haWorkflowUtils";
function Step4() {
const { clusterName } = useContext(AppContext);
@@ -36,22 +37,18 @@ function Step4() {
} = useContext(EnableHighAvailibilityContext);
const [namenodeHost, setNamenodeHost] = useState("");
const [isNextEnabled, setIsNextEnabled] = useState(false);
- const { stopPolling } = usePolling(pullCheckPointStatus);
+ const [isNameNodeStarted, setIsNameNodeStarted] = useState(true);
+ const [pollError, setPollError] = useState("");
+ const [kdcError, setKdcError] = useState("");
+ const { pausePolling } = usePolling(pullCheckPointStatus);
const { getKDCSessionState } = useKDCSessionState(() => {});
-
- function getNnCheckPointStatus(data: any) {
- const isInSafeMode = !isEmpty(get(data, "metrics.dfs.namenode.Safemode"));
- let journalTransactionInfo = JSON.parse(
- get(data, "metrics.dfs.namenode.JournalTransactionInfo")
- );
-
- // in case when transaction info absent or invalid return 2 which will return false in next `if` statement
- journalTransactionInfo = !!journalTransactionInfo
- ? parseInt(journalTransactionInfo.LastAppliedOrWrittenTxId) -
- parseInt(journalTransactionInfo.MostRecentCheckpointTxId)
- : 2;
- return journalTransactionInfo <= 1 && isInSafeMode;
- }
+ const hdfsUser =
+ getStepData(
+ state,
+ "GET_STARTED",
+ "hdfsUser",
+ "enableHighAvailibilitySteps",
+ ) || "hdfs";
async function pullCheckPointStatus() {
const masterComponentHosts = getStepData(
@@ -68,14 +65,20 @@ function Step4() {
try {
const data = await adminApi.getNnCheckPointStatus(clusterName, hostName);
- // const isNamenodeStarted = data.HostRoles.desired_state === "STARTED";
- const shouldEnableNext = getNnCheckPointStatus(data);
- if (shouldEnableNext) {
+ const evaluation = evaluateNameNodeCheckpoint(data);
+ setIsNameNodeStarted(evaluation.started);
+ setPollError(evaluation.error || "");
+ setIsNextEnabled(evaluation.ready);
+ if (evaluation.ready) {
setIsNextEnabled(true);
- stopPolling();
+ pausePolling();
}
} catch (err) {
console.error("Error in fetching checkpoint status", err);
+ setIsNextEnabled(false);
+ setPollError(
+ "Ambari could not read the NameNode checkpoint status. Polling will retry.",
+ );
}
}
@@ -96,13 +99,13 @@ function Step4() {
<li className="mt-3 fs-12">
Put the NameNode in Safe Mode (read only mode):
<div className="code-snippet fs-12 mt-2">
- sudo su hdfs -l -c 'hdfs dfsadmin -safemode enter'
+ sudo su {hdfsUser} -l -c 'hdfs dfsadmin -safemode enter'
</div>
</li>
<li className="mt-3 fs-12">
Once in Safe Mode, create a Checkpoint:
<div className="code-snippet mt-2">
- sudo su hdfs -l -c 'hdfs dfsadmin -saveNamespace'
+ sudo su {hdfsUser} -l -c 'hdfs dfsadmin -saveNamespace'
</div>
</li>
<li className="mt-3 fs-12">
@@ -121,6 +124,21 @@ function Step4() {
command, it means there is a recent Checkpoint already and you may
proceed without running the "Step 4: Create a Checkpoint" command.
</Alert>
+ {!isNameNodeStarted ? (
+ <Alert variant="danger" className="mt-3">
+ The current NameNode is not started.
+ </Alert>
+ ) : null}
+ {pollError ? (
+ <Alert variant="danger" className="mt-3">
+ {pollError}
+ </Alert>
+ ) : null}
+ {kdcError ? (
+ <Alert variant="danger" className="mt-3">
+ {kdcError}
+ </Alert>
+ ) : null}
</Card.Body>
</Card>
</div>
@@ -133,13 +151,23 @@ function Step4() {
isNextEnabled={isNextEnabled}
sideItems={isNextEnabled?"Checkpoint created":null}
onNext={() => {
- getKDCSessionState(() => {
- flushStateToDb("next");
- handleNextImperitive();
- });
+ setKdcError("");
+ void getKDCSessionState(
+ async () => {
+ await flushStateToDb("next");
+ await handleNextImperitive();
+ },
+ (error: any) => {
+ setKdcError(
+ error?.response?.data?.message ||
+ error?.message ||
+ "Ambari could not validate the KDC session.",
+ );
+ },
+ );
}}
onCancel={() => {
- flushStateToDb("cancel");
+ void flushStateToDb("cancel");
}}
/>
</>
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step5.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step5.tsx
index c1823fe2bd..cd50162176 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step5.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step5.tsx
@@ -21,7 +21,7 @@ import { RequestApi } from "../../../../api/requestApi";
import { AppContext } from "../../../../store/context";
import { EnableHighAvailibilityContext } from "./store/context";
import { getStepData, role } from "../../../../Utils/Utility";
-import { filter, find, forEach, get, has, map, uniq } from "lodash";
+import { filter, find, has, map, uniq } from "lodash";
import {
createInstallComponentTask,
reconfigureSites,
@@ -35,6 +35,11 @@ import ConfigsApi from "../../../../api/configsApi";
import WizardFooter from "../../../../components/StepWizard/WizardFooter";
import { ActionTypes } from "./store/types";
import useKDCSessionState from "../../../../hooks/useKDCSessionState";
+import {
+ buildDesiredConfigQuery,
+ mergeReviewedConfigs,
+ mergeSavedOperations,
+} from "../haWorkflowUtils";
function Step5() {
const { clusterName, services, isKerberosEnabled } = useContext(AppContext);
@@ -56,40 +61,27 @@ function Step5() {
"enableHighAvailibilitySteps"
);
const selectedServices = map(services, "ServiceInfo.service_name");
- const mergeConfigProperties = (item: any, configTypeObject: any): any => {
- if (!configTypeObject) {
- return item;
- }
-
- return {
- ...item,
- properties: {
- ...item.properties,
- ...configTypeObject.properties,
- },
- };
- };
-
- async function updateConfigProperties(configItems: any) {
- console.log("Confog Items are", configItems);
- const data = getStepData(
- state,
- enableNamenodeSteps.REVIEW,
- "overridenProperties",
- "enableHighAvailibilitySteps"
- );
+ async function updateConfigProperties(configData: any) {
const siteNames = ["hdfs-site", "core-site"].concat(
- getRangerSiteNames(data)
+ getRangerSiteNames(configData)
);
+ const missingSites = siteNames.filter(
+ (siteName) => !find(configData?.items, ["type", siteName]),
+ );
+ if (missingSites.length) {
+ throw new Error(
+ `The NameNode HA configuration is missing: ${missingSites.join(", ")}.`,
+ );
+ }
const note = `This configuration is created by Enable ${role(
"NAMENODE",
false
)} HA wizard`;
- const configData = reconfigureSites(siteNames, data, note);
+ const desiredConfig = reconfigureSites(siteNames, configData, note);
try {
await ConfigsApi.updateServiceConfigurations(clusterName, {
- desired_config: configData,
+ desired_config: desiredConfig,
});
const nnHostNames = map(
filter(masterComponentHosts, ["component", "NAMENODE"]),
@@ -111,9 +103,42 @@ function Step5() {
);
} catch (err) {
console.error("Could not update configs", err);
+ throw err;
}
}
+ async function loadCurrentSecureConfigs(reviewedConfigs: any) {
+ const siteNames = ["hdfs-site", "core-site"].concat(
+ getRangerSiteNames(reviewedConfigs),
+ );
+ const tagsResponse = await ConfigsApi.loadConfigTags(clusterName);
+ const query = buildDesiredConfigQuery(
+ tagsResponse?.Clusters?.desired_configs,
+ siteNames,
+ );
+ const currentConfigs = await ConfigsApi.getConfigsByTags(clusterName, query);
+ const loadedTypes = new Set(
+ (currentConfigs?.items || []).map((item: any) => item.type),
+ );
+ const missingSites = siteNames.filter((siteName) => !loadedTypes.has(siteName));
+ if (missingSites.length) {
+ throw new Error(
+ `Ambari did not return the current configuration for: ${missingSites.join(", ")}.`,
+ );
+ }
+ const configsToRemove = getStepData(
+ state,
+ enableNamenodeSteps.REVIEW,
+ "configsToRemove",
+ "enableHighAvailibilitySteps",
+ );
+ return mergeReviewedConfigs(
+ currentConfigs,
+ reviewedConfigs,
+ configsToRemove,
+ );
+ }
+
function getRangerSiteNames(data: any) {
var siteNames = [];
if (selectedServices.includes("RANGER")) {
@@ -222,70 +247,17 @@ function Step5() {
label: "Reconfigure HDFS",
skippable: false,
callback: async () => {
- const data = getStepData(
+ const reviewedConfigs = getStepData(
state,
enableNamenodeSteps.REVIEW,
"overridenProperties",
"enableHighAvailibilitySteps"
);
if (isKerberosEnabled) {
- //@ts-ignore
- const stepConfigs = getStepData(
- state,
- enableNamenodeSteps.REVIEW,
- "stepConfigs",
- "enableHighAvailibilitySteps"
- );
- const data = getStepData(
- state,
- enableNamenodeSteps.REVIEW,
- "overridenProperties",
- "enableHighAvailibilitySteps"
- );
- let configItems = data.items.map(function (item: any) {
- var fileName = get(item, "type");
- var configTypeObject = find(data.items, ["type", fileName]);
- if (configTypeObject) {
- mergeConfigProperties(
- item.properties,
- configTypeObject.properties
- );
- }
- return item;
- });
- console.log("State is", state);
- const configsToRemove = getStepData(
- state,
- enableNamenodeSteps.REVIEW,
- "configsToRemove",
- "enableHighAvailibilitySteps"
- );
- forEach(configsToRemove, (properties: any, type: string) => {
- let matchingConfigItem = find(configItems, (item) => {
- return item.type === type;
- });
- for (let toBeDeletedProperty of properties) {
- if (
- matchingConfigItem &&
- matchingConfigItem.properties &&
- has(matchingConfigItem.properties, toBeDeletedProperty)
- ) {
- delete matchingConfigItem.properties[toBeDeletedProperty];
- }
- }
- configItems = configItems.map((item: any) => {
- if (item.type === type) {
- return matchingConfigItem;
- }
- return item;
- });
- });
- const result = await updateConfigProperties(configItems);
- return result || { status: 200 }; // Return success status for OperationsProgress
- } else {
- const result = await updateConfigProperties(data);
- return result || { status: 200 }; // Return success status for OperationsProgress
+ const currentConfigs = await loadCurrentSecureConfigs(reviewedConfigs);
+ return await updateConfigProperties(currentConfigs);
}
+ return await updateConfigProperties(reviewedConfigs);
},
},
...(isKerberosEnabled
@@ -309,6 +281,7 @@ function Step5() {
);
} catch (error) {
console.log("Error regenerating keytabs: ", error);
+ throw error;
}
},
},
@@ -362,21 +335,7 @@ function Step5() {
);
useEffect(() => {
- const finalOperations = (() => {
- if (savedOperationsState && Array.isArray(savedOperationsState)) {
- return operations.map((originalOp) => {
- const savedOp = savedOperationsState.find(
- (saved: any) => saved.id === originalOp.id
- );
- return savedOp
- ? { ...originalOp, ...savedOp, callback: originalOp.callback }
- : originalOp;
- });
- }
-
- return operations;
- })();
- setStepOperations(finalOperations);
+ setStepOperations(mergeSavedOperations(operations, savedOperationsState));
}, [JSON.stringify(savedOperationsState)]);
if (!stepOperations || stepOperations.length === 0) {
@@ -391,7 +350,7 @@ function Step5() {
description=""
setCompletionStatus={setCompletionStatus}
operations={stepOperations as any}
- dispatch={(operationsState: any) => {
+ dispatch={async (operationsState: any) => {
dispatch({
type: ActionTypes.STORE_INFORMATION,
payload: {
@@ -401,22 +360,24 @@ function Step5() {
},
},
});
+ await flushStateToDb();
}}
/>
<WizardFooter
step={currentStep}
isNextEnabled={completionStatus}
- onNext={() => {
- flushStateToDb("next");
- handleNextImperitive();
+ onNext={async () => {
+ await flushStateToDb("next");
+ await handleNextImperitive();
}}
onBack={() => {
flushStateToDb("back");
jumpToStep(4);
}}
onCancel={() => {
- flushStateToDb("cancel");
+ void flushStateToDb("cancel");
}}
+ cancelConfirmationBody="NameNode HA changes have started. Exiting preserves the workflow checkpoint so the operation can be resumed. Complete the documented manual recovery before making further HDFS topology changes."
/>
</>
);
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step6.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step6.tsx
index 12fddb303a..15a3308b9b 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step6.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step6.tsx
@@ -16,7 +16,7 @@
* limitations under the License.
*/
-import { useContext, useEffect, useRef, useState } from "react";
+import { useContext, useState } from "react";
import { EnableHighAvailibilityContext } from "./store/context";
import { getStepData } from "../../../../Utils/Utility";
import { filter, find, map } from "lodash";
@@ -24,8 +24,9 @@ import { AppContext } from "../../../../store/context";
import adminApi from "../../../../api/adminApi";
import { enableNamenodeSteps } from "./wizardSteps";
import WizardFooter from "../../../../components/StepWizard/WizardFooter";
-import { Card, ListGroup } from "react-bootstrap";
+import { Alert, Card, ListGroup } from "react-bootstrap";
import usePolling from "../../../../hooks/usePolling";
+import { evaluateJournalNodeFormatSet } from "../haWorkflowUtils";
function Step6() {
const {
@@ -34,18 +35,24 @@ function Step6() {
stepWizardUtilities: { currentStep, handleNextImperitive, jumpToStep },
} = useContext(EnableHighAvailibilityContext);
const { clusterName } = useContext(AppContext);
- const status = useRef("waiting");
- const initJnCounter = useRef(0);
- const requestsCounter = useRef(0);
- const hasStoppedJNs = useRef(false);
- const [isNextEnabled, setIsNextEnabled] = useState(true);
- const MINIMAL_JOURNALNODE_COUNT = 3;
+ const [isNextEnabled, setIsNextEnabled] = useState(false);
+ const [statusMessage, setStatusMessage] = useState(
+ "Waiting for all JournalNodes to be formatted.",
+ );
+ const [pollError, setPollError] = useState("");
const nameServiceId = getStepData(
state,
enableNamenodeSteps.GET_STARTED,
"nameserviceId",
"enableHighAvailibilitySteps"
);
+ const hdfsUser =
+ getStepData(
+ state,
+ enableNamenodeSteps.GET_STARTED,
+ "hdfsUser",
+ "enableHighAvailibilitySteps",
+ ) || "hdfs";
const masterComponentHosts = getStepData(
state,
"SELECT_HOSTS",
@@ -53,67 +60,55 @@ function Step6() {
"enableHighAvailibilitySteps"
);
- const {stopPolling,pausePolling,resumePolling}=usePolling(pullCheckPointStatus)
-
- function resolveJnCheckPointStatus(jNCounter: number) {
- if (jNCounter === MINIMAL_JOURNALNODE_COUNT) {
- status.current = "done";
- pausePolling();
- setIsNextEnabled(true);
- } else {
- if (hasStoppedJNs.current) {
- status.current = "journalnode_stopped";
- } else {
- status.current = "waiting";
- }
- resumePolling();
- }
- }
+ const { pausePolling } = usePolling(pullCheckPointStatus);
- async function pullEachJnStatus(hostName: string) {
- try {
- const data = await adminApi.getJnCheckPointStatus(clusterName, hostName);
- let journalStatusInfo;
- let jNCounter = 0;
- if (data?.metrics?.dfs) {
- journalStatusInfo = JSON.parse(
- data.metrics.dfs.journalnode.journalsStatus
- );
- if (
- journalStatusInfo[nameServiceId] &&
- journalStatusInfo[nameServiceId].Formatted === "true"
- ) {
- jNCounter += 1;
- } else {
- hasStoppedJNs.current = true;
- }
- requestsCounter.current = requestsCounter.current + 1;
- if (requestsCounter.current === MINIMAL_JOURNALNODE_COUNT) {
- resolveJnCheckPointStatus(jNCounter);
- }
- }
- } catch (err) {
- console.error("Could not fetch jn status", err);
- }
- }
- function pullCheckPointStatus() {
- initJnCounter.current = 0;
- requestsCounter.current = 0;
- hasStoppedJNs.current = false;
+ async function pullCheckPointStatus() {
const hostNames = map(
filter(masterComponentHosts, ["component", "JOURNALNODE"]),
"hostName"
);
- hostNames.forEach(async (hostName: string) => {
- await pullEachJnStatus(hostName);
- });
- }
- useEffect(() => {
- pullCheckPointStatus();
- return ()=>{
- stopPolling()
+ const responses: Record<string, unknown> = {};
+ const requestErrors: string[] = [];
+ await Promise.all(
+ hostNames.map(async (hostName: string) => {
+ try {
+ responses[hostName] = await adminApi.getJnCheckPointStatus(
+ clusterName,
+ hostName,
+ );
+ } catch (error) {
+ console.error(`Could not fetch JournalNode status for ${hostName}`, error);
+ requestErrors.push(hostName);
+ }
+ }),
+ );
+
+ const evaluation = evaluateJournalNodeFormatSet(
+ hostNames,
+ responses,
+ nameServiceId,
+ );
+ if (requestErrors.length) {
+ setPollError(
+ `Ambari could not read JournalNode status for ${requestErrors.join(", ")}. Polling will retry.`,
+ );
+ } else {
+ setPollError(evaluation.error || "");
+ }
+ setIsNextEnabled(evaluation.ready);
+ if (evaluation.ready) {
+ setStatusMessage("All selected JournalNodes are formatted.");
+ pausePolling();
+ } else if (evaluation.missingHosts.length) {
+ setStatusMessage(
+ `Waiting for JournalNodes: ${evaluation.missingHosts.join(", ")}`,
+ );
+ } else if (evaluation.invalidHosts.length) {
+ setStatusMessage(
+ `JournalNodes not formatted: ${evaluation.invalidHosts.join(", ")}`,
+ );
}
- }, []);
+ }
const namenodeHost = find(
filter(masterComponentHosts, ["component", "NAMENODE"]),
@@ -136,7 +131,7 @@ function Step6() {
<li className="mt-3 fs-12">
Initialize the JournalNodes by running:
<div className="code-snippet fs-12 mt-2">
- sudo su hdfs -l -c 'hdfs namenode -initializeSharedEdits'
+ sudo su {hdfsUser} -l -c 'hdfs namenode -initializeSharedEdits'
</div>
</li>
<li className="mt-3 fs-12">
@@ -145,6 +140,11 @@ function Step6() {
</li>
</ol>
</ListGroup>
+ {pollError ? (
+ <Alert variant="danger" className="mt-3">
+ {pollError}
+ </Alert>
+ ) : null}
</Card.Body>
</Card>
</div>
@@ -155,13 +155,15 @@ function Step6() {
}}
step={currentStep}
isNextEnabled={isNextEnabled}
- onNext={() => {
- flushStateToDb("next");
- handleNextImperitive();
+ sideItems={statusMessage}
+ onNext={async () => {
+ await flushStateToDb("next");
+ await handleNextImperitive();
}}
onCancel={() => {
- flushStateToDb("cancel");
+ void flushStateToDb("cancel");
}}
+ cancelConfirmationBody="NameNode HA changes have started. Exiting preserves the workflow checkpoint so the operation can be resumed. Complete the documented manual recovery before making further HDFS topology changes."
/>
</>
);
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step7.test.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step7.test.tsx
new file mode 100644
index 0000000000..4140f6d6dd
--- /dev/null
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step7.test.tsx
@@ -0,0 +1,114 @@
+/**
+ * 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 { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { AppContext } from "../../../../store/context";
+import { EnableHighAvailibilityContext } from "./store/context";
+
+const mocks = vi.hoisted(() => ({
+ getClusterComponents: vi.fn(),
+}));
+
+vi.mock("../../../../api/hostsApi", () => ({
+ HostsApi: { getClusterComponents: mocks.getClusterComponents },
+}));
+vi.mock("../../../../Utils/taskUtils", () => ({
+ startServices: vi.fn(),
+ updateComponent: vi.fn(),
+}));
+vi.mock("../../../../components/OperationsProgress", () => ({
+ default: () => <div>Operations ready</div>,
+}));
+vi.mock("../../../../components/StepWizard/WizardFooter", () => ({
+ default: () => null,
+}));
+
+import Step7 from "./Step7";
+
+const wizardState = {
+ enableHighAvailibilitySteps: {
+ SELECT_HOSTS: {
+ data: {
+ masterComponentHosts: [
+ { component: "NAMENODE", hostName: "nn1", isInstalled: true },
+ ],
+ },
+ },
+ },
+};
+
+function renderStep() {
+ return render(
+ <AppContext.Provider
+ value={{ clusterName: "c1", services: [] } as never}
+ >
+ <EnableHighAvailibilityContext.Provider
+ value={
+ {
+ state: wizardState,
+ dispatch: vi.fn(),
+ flushStateToDb: vi.fn(),
+ stepWizardUtilities: {
+ currentStep: { name: "START_COMPONENTS" },
+ handleNextImperitive: vi.fn(),
+ jumpToStep: vi.fn(),
+ },
+ } as never
+ }
+ >
+ <Step7 />
+ </EnableHighAvailibilityContext.Provider>
+ </AppContext.Provider>,
+ );
+}
+
+describe("NameNode HA start-components topology", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ it("shows a retryable error when the authoritative topology load fails", async () => {
+ mocks.getClusterComponents
+ .mockRejectedValueOnce(new Error("topology unavailable"))
+ .mockResolvedValue({
+ items: [
+ {
+ ServiceComponentInfo: {
+ component_name: "ZOOKEEPER_SERVER",
+ service_name: "ZOOKEEPER",
+ installed_count: 3,
+ },
+ host_components: [
+ { HostRoles: { host_name: "zk1" } },
+ { HostRoles: { host_name: "zk2" } },
+ { HostRoles: { host_name: "zk3" } },
+ ],
+ },
+ ],
+ });
+
+ renderStep();
+
+ expect(await screen.findByText("topology unavailable")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(await screen.findByText("Operations ready")).toBeTruthy();
+ expect(mocks.getClusterComponents).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step7.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step7.tsx
index ad7fb53eff..8a8db704a7 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step7.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step7.tsx
@@ -19,14 +19,16 @@
import { useContext, useEffect, useState } from "react";
import { getStepData } from "../../../../Utils/Utility";
import { EnableHighAvailibilityContext } from "./store/context";
-import { filter, find, map } from "lodash";
+import { filter, find, get, map } from "lodash";
import { startServices, updateComponent } from "../../../../Utils/taskUtils";
import { AppContext } from "../../../../store/context";
-import { ServiceContext } from "../../../../store/ServiceContext";
import OperationsProgress from "../../../../components/OperationsProgress";
import WizardFooter from "../../../../components/StepWizard/WizardFooter";
import { ActionTypes } from "./store/types";
import { enableNamenodeSteps } from "./wizardSteps";
+import { mergeSavedOperations } from "../haWorkflowUtils";
+import { HostsApi } from "../../../../api/hostsApi";
+import { Alert, Button } from "react-bootstrap";
enum COMMANDS {
startZooKeeperServers = "startZooKeeperServers",
@@ -44,9 +46,10 @@ function Step7() {
stepWizardUtilities: { currentStep, handleNextImperitive, jumpToStep },
} = useContext(EnableHighAvailibilityContext);
const { clusterName, services } = useContext(AppContext);
- const { serviceModels: allServiceModels }: any = useContext(ServiceContext);
const [completionStatus, setCompletionStatus] = useState(false);
const [stepOperations, setStepOperations] = useState<any>([]);
+ const [topologyError, setTopologyError] = useState("");
+ const [topologyRetry, setTopologyRetry] = useState(0);
const masterComponentHosts = getStepData(
state,
"SELECT_HOSTS",
@@ -56,48 +59,48 @@ function Step7() {
const selectedServices = map(services, "ServiceInfo.service_name");
- function initializeTasks() {
- let id = 0;
+ function initializeTasks(componentItems: any[]) {
const allOps = [];
- const tasksToRemove = [];
- const rangerMasterComponents =
- allServiceModels?.["ranger"]?.masterComponents;
- if (!selectedServices.includes("AMBARI_INFRA_SOLR")) {
- tasksToRemove.push(COMMANDS.startAmbariInfra);
- }
+ const component = (componentName: string) =>
+ find(componentItems, [
+ "ServiceComponentInfo.component_name",
+ componentName,
+ ]);
+ const componentHosts = (componentName: string) =>
+ map(
+ get(component(componentName), "host_components", []),
+ (hostComponent: any) => get(hostComponent, "HostRoles.host_name"),
+ ).filter(Boolean);
+ const isInstalled = (componentName: string) =>
+ Number(
+ get(component(componentName), "ServiceComponentInfo.installed_count", 0),
+ ) > 0;
+ const hasInfraModel = componentItems.some(
+ (item: any) =>
+ get(item, "ServiceComponentInfo.service_name") ===
+ "AMBARI_INFRA_SOLR",
+ );
+
+ allOps.push({
+ id: COMMANDS.startZooKeeperServers,
+ label: "Start Zookeeper Servers",
+ skippable: false,
+ callback: async () =>
+ await updateComponent(
+ clusterName,
+ "ZOOKEEPER_SERVER",
+ componentHosts("ZOOKEEPER_SERVER"),
+ "ZOOKEEPER",
+ "Start",
+ 1,
+ ),
+ });
if (
- !selectedServices.includes("RANGER")||
- find(rangerMasterComponents, ["componentName", "RANGER_ADMIN"])
- ?.installedCount === 0
+ selectedServices.includes("AMBARI_INFRA_SOLR") &&
+ hasInfraModel
) {
- tasksToRemove.push(COMMANDS.startRanger);
- }
- tasksToRemove.push(COMMANDS.startMysqlServer);
-
- if (!tasksToRemove.includes(COMMANDS.startZooKeeperServers)) {
- allOps.push({
- id: id++,
- label: "Start Zookeeper Servers",
- skippable: false,
- callback: async () => {
- const hostNames = map(
- filter(masterComponentHosts, ["component", "ZOOKEEPER_SERVER"]),
- "hostName"
- );
- return await updateComponent(
- clusterName,
- "ZOOKEEPER_SERVER",
- hostNames,
- "HDFS",
- "Start",
- 1
- );
- },
- });
- }
- if (!tasksToRemove.includes(COMMANDS.startAmbariInfra)) {
allOps.push({
- id: id++,
+ id: COMMANDS.startAmbariInfra,
label: "Start Ambari Infra",
skippable: false,
callback: async () => {
@@ -110,83 +113,139 @@ function Step7() {
},
});
}
- if (!tasksToRemove.includes(COMMANDS.startRanger)) {
- const hostNames = map(
- filter(masterComponentHosts, ["component", "RANGER_ADMIN"]),
- "hostName"
- );
+ if (isInstalled("MYSQL_SERVER")) {
allOps.push({
- id: id++,
- label: "Start Ranger",
+ id: COMMANDS.startMysqlServer,
+ label: "Start MySQL Server",
skippable: false,
- callback: async () => {
- return await updateComponent(
+ callback: async () =>
+ await updateComponent(
clusterName,
- "RANGER_ADMIN",
- hostNames,
- "RANGER",
+ "MYSQL_SERVER",
+ componentHosts("MYSQL_SERVER"),
+ "HIVE",
"Start",
- 1
- );
- },
+ 1,
+ ),
});
}
- if (!tasksToRemove.includes(COMMANDS.startNameNode)) {
- const hostName=map(filter(masterComponentHosts, (masterComponentHost:any)=>{
- return masterComponentHost.component === "NAMENODE" && masterComponentHost.isInstalled;
- }),"hostName");
+ if (selectedServices.includes("RANGER") && isInstalled("RANGER_ADMIN")) {
allOps.push({
- id: id++,
- label: "Start NameNode",
+ id: COMMANDS.startRanger,
+ label: "Start Ranger",
skippable: false,
callback: async () => {
return await updateComponent(
clusterName,
- "NAMENODE",
- hostName,
- "HDFS",
+ "RANGER_ADMIN",
+ componentHosts("RANGER_ADMIN"),
+ "RANGER",
"Start",
1
);
},
});
}
+ const currentNameNodeHost = map(
+ filter(
+ masterComponentHosts,
+ (masterComponentHost: any) =>
+ masterComponentHost.component === "NAMENODE" &&
+ masterComponentHost.isInstalled,
+ ),
+ "hostName",
+ );
+ allOps.push({
+ id: COMMANDS.startNameNode,
+ label: "Start NameNode",
+ skippable: false,
+ callback: async () =>
+ await updateComponent(
+ clusterName,
+ "NAMENODE",
+ currentNameNodeHost,
+ "HDFS",
+ "Start",
+ 1,
+ ),
+ });
return allOps;
}
- const savedOperationsState = getStepData(
+ const savedOperationsState = getStepData(
state,
enableNamenodeSteps.START_COMPONENTS,
"operationsState",
"enableHighAvailibilitySteps"
);
- useEffect(()=>{
- const operations = (() => {
- const initialOperations = initializeTasks();
- if (savedOperationsState && Array.isArray(savedOperationsState)) {
- return initialOperations.map((originalOp) => {
- const savedOp = savedOperationsState.find(
- (saved: any) => saved.id === originalOp.id
+ useEffect(() => {
+ let cancelled = false;
+ const loadTopology = async () => {
+ setTopologyError("");
+ setStepOperations([]);
+ try {
+ const fields =
+ "ServiceComponentInfo/service_name,ServiceComponentInfo/component_name," +
+ "ServiceComponentInfo/installed_count,host_components/HostRoles/host_name";
+ const response = await HostsApi.getClusterComponents(clusterName, fields);
+ const componentItems = response?.items || [];
+ const zooKeeper = find(componentItems, [
+ "ServiceComponentInfo.component_name",
+ "ZOOKEEPER_SERVER",
+ ]);
+ const currentNameNode = find(
+ masterComponentHosts || [],
+ (item: any) => item.component === "NAMENODE" && item.isInstalled,
+ );
+ if (
+ !componentItems.length ||
+ !get(zooKeeper, "host_components", []).length ||
+ !currentNameNode?.hostName
+ ) {
+ throw new Error(
+ "Ambari could not resolve the ZooKeeper and current NameNode topology.",
+ );
+ }
+ if (!cancelled) {
+ setStepOperations(
+ mergeSavedOperations(
+ initializeTasks(componentItems),
+ savedOperationsState,
+ ),
+ );
+ }
+ } catch (error: any) {
+ if (!cancelled) {
+ setTopologyError(
+ error?.response?.data?.message ||
+ error?.message ||
+ "Ambari could not load the component topology.",
);
- return savedOp
- ? { ...originalOp, ...savedOp, callback: originalOp.callback }
- : originalOp;
- });
+ }
}
-
- return initialOperations;
- })();
- setStepOperations(operations);
- },[JSON.stringify(savedOperationsState)])
-
- useEffect(() => {
- initializeTasks();
- }, []);
-
-
-
-
-
-
+ };
+ void loadTopology();
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ clusterName,
+ topologyRetry,
+ JSON.stringify(savedOperationsState),
+ ]);
+ if (topologyError) {
+ return (
+ <Alert variant="danger">
+ {topologyError}
+ <Button
+ size="sm"
+ className="ms-3"
+ onClick={() => setTopologyRetry((value) => value + 1)}
+ >
+ Retry
+ </Button>
+ </Alert>
+ );
+ }
if (!stepOperations || stepOperations.length === 0) {
return <div>Loading...</div>;
}
@@ -199,7 +258,7 @@ function Step7() {
description=""
setCompletionStatus={setCompletionStatus}
operations={stepOperations as any}
- dispatch={(operationsState: any) => {
+ dispatch={async (operationsState: any) => {
dispatch({
type: ActionTypes.STORE_INFORMATION,
payload: {
@@ -209,25 +268,27 @@ function Step7() {
},
},
});
+ await flushStateToDb();
}}
/>
<WizardFooter
step={currentStep}
isNextEnabled={completionStatus}
- onNext={() => {
- flushStateToDb("next");
- handleNextImperitive();
+ onNext={async () => {
+ await flushStateToDb("next");
+ await handleNextImperitive();
}}
onBack={() => {
jumpToStep(6);
flushStateToDb("back");
}}
onCancel={() => {
- flushStateToDb("cancel");
+ void flushStateToDb("cancel");
}}
+ cancelConfirmationBody="NameNode HA changes have started. Exiting preserves the workflow checkpoint so the operation can be resumed. Complete the documented manual recovery before making further HDFS topology changes."
/>
</>
);
}
-export default Step7;
\ No newline at end of file
+export default Step7;
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step8.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step8.tsx
index 82d953492a..dcf7f9fc6a 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step8.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step8.tsx
@@ -23,6 +23,7 @@ import WizardFooter from "../../../../components/StepWizard/WizardFooter";
import { filter, find } from "lodash";
import { getStepData } from "../../../../Utils/Utility";
import useKDCSessionState from "../../../../hooks/useKDCSessionState";
+import ConfirmationModal from "../../../../components/ConfirmationModal";
function Step8() {
const {
@@ -33,6 +34,10 @@ function Step8() {
const [isNextEnabled] = useState(true);
const [namenodeHost, setNamenodeHost] = useState("");
const [additionalNamenodeHost, setAdditionalNamenodeHost] = useState("");
+ const [showCompletionConfirmation, setShowCompletionConfirmation] =
+ useState(false);
+ const [validationError, setValidationError] = useState("");
+ const [isFinalizing, setIsFinalizing] = useState(false);
const { getKDCSessionState } = useKDCSessionState(() => {});
const masterComponentHosts = getStepData(
@@ -41,10 +46,13 @@ function Step8() {
"masterComponentHosts",
"enableHighAvailibilitySteps"
);
- console.log("State is", state);
- // useEffect(() => {
- // handleNextImperitive();
- // }, []);
+ const hdfsUser =
+ getStepData(
+ state,
+ "GET_STARTED",
+ "hdfsUser",
+ "enableHighAvailibilitySteps",
+ ) || "hdfs";
useEffect(() => {
const hostName = find(
filter(masterComponentHosts, ["component", "NAMENODE"]),
@@ -60,6 +68,31 @@ function Step8() {
return (
<>
+ <ConfirmationModal
+ isOpen={showCompletionConfirmation}
+ onClose={() => setShowCompletionConfirmation(false)}
+ modalTitle="Confirm Manual Steps"
+ modalBody="Confirm that formatZK and bootstrapStandby completed successfully before finalizing NameNode HA."
+ isOkDisabled={isFinalizing}
+ successCallback={async () => {
+ setIsFinalizing(true);
+ setValidationError("");
+ try {
+ await flushStateToDb("next");
+ await handleNextImperitive();
+ setShowCompletionConfirmation(false);
+ } catch (error: any) {
+ setShowCompletionConfirmation(false);
+ setValidationError(
+ error?.response?.data?.message ||
+ error?.message ||
+ "Ambari could not persist the NameNode HA checkpoint.",
+ );
+ } finally {
+ setIsFinalizing(false);
+ }
+ }}
+ />
<div>
<div className="step-title">
Manual Steps Required: Create Checkpoint on NameNode
@@ -76,7 +109,7 @@ function Step8() {
Initialize the metadata for NameNode automatic failover by
running:
<div className="code-snippet fs-12 mt-2">
- sudo su hdfs -l -c 'hdfs zkfc -formatZK'
+ sudo su {hdfsUser} -l -c 'hdfs zkfc -formatZK'
</div>
</li>
<li className="mt-3 fs-12">
@@ -98,7 +131,7 @@ function Step8() {
Initialize the metadata for the Additional NameNode by
running:
<div className="code-snippet fs-12 mt-2">
- sudo su hdfs -l -c 'hdfs namenode -bootstrapStandby'
+ sudo su {hdfsUser} -l -c 'hdfs namenode -bootstrapStandby'
</div>
</li>
</ol>
@@ -108,6 +141,11 @@ function Step8() {
<div className="fs-12">
Please proceed once you have completed the steps above.
</div>
+ {validationError ? (
+ <Alert variant="danger" className="mt-3">
+ {validationError}
+ </Alert>
+ ) : null}
</div>
<WizardFooter
onBack={() => {
@@ -117,14 +155,22 @@ function Step8() {
step={currentStep}
isNextEnabled={isNextEnabled}
onNext={() => {
- getKDCSessionState(() => {
- flushStateToDb("next");
- handleNextImperitive();
- });
+ setValidationError("");
+ void getKDCSessionState(
+ () => setShowCompletionConfirmation(true),
+ (error: any) => {
+ setValidationError(
+ error?.response?.data?.message ||
+ error?.message ||
+ "Ambari could not validate the KDC session.",
+ );
+ },
+ );
}}
onCancel={() => {
- flushStateToDb("cancel");
+ void flushStateToDb("cancel");
}}
+ cancelConfirmationBody="NameNode HA changes have started. Exiting preserves the workflow checkpoint so the operation can be resumed. Complete the documented manual recovery before making further HDFS topology changes."
/>
</>
);
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step9.test.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step9.test.tsx
new file mode 100644
index 0000000000..d066c4f788
--- /dev/null
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step9.test.tsx
@@ -0,0 +1,142 @@
+/**
+ * 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 { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { AppContext } from "../../../../store/context";
+import { ServiceContext } from "../../../../store/ServiceContext";
+import { EnableHighAvailibilityContext } from "./store/context";
+
+const mocks = vi.hoisted(() => ({
+ modalShow: vi.fn(),
+ modalHide: vi.fn(),
+}));
+
+vi.mock("../../../../store/ModalManager", () => ({
+ default: { show: mocks.modalShow, hide: mocks.modalHide },
+}));
+vi.mock("i18next", () => ({
+ t: (key: string) =>
+ ({
+ "admin.highAvailability.step9.save.configuration.note":
+ "NameNode HA configuration",
+ "admin.highAvailability.wizard.step9.hawq.confirmPopup.header":
+ "Additional Steps Required for HAWQ",
+ "admin.highAvailability.wizard.step9.hawq.confirmPopup.body":
+ "Follow HAWQ Filespaces and High Availability Enabled HDFS.",
+ })[key],
+}));
+vi.mock("../../../../hooks/useKDCSessionState", () => ({
+ default: () => ({ getKDCSessionState: vi.fn() }),
+}));
+vi.mock("../../../../components/OperationsProgress", () => ({
+ default: ({ setCompletionStatus }: { setCompletionStatus: (value: boolean) => void }) => (
+ <button onClick={() => setCompletionStatus(true)}>Finish operations</button>
+ ),
+}));
+vi.mock("../../../../components/StepWizard/WizardFooter", () => ({
+ default: ({
+ onNext,
+ isNextEnabled,
+ }: {
+ onNext: () => void;
+ isNextEnabled: boolean;
+ }) => (
+ <button disabled={!isNextEnabled} onClick={onNext}>
+ Complete wizard
+ </button>
+ ),
+}));
+
+import Step9 from "./Step9";
+
+const wizardState = {
+ enableHighAvailibilitySteps: {
+ SELECT_HOSTS: {
+ data: {
+ masterComponentHosts: [
+ { component: "NAMENODE", hostName: "nn1", isInstalled: true },
+ { component: "NAMENODE", hostName: "nn2", isInstalled: false },
+ {
+ component: "SECONDARY_NAMENODE",
+ hostName: "snn1",
+ isInstalled: true,
+ },
+ ],
+ },
+ },
+ REVIEW: { data: { overridenProperties: { items: [] } } },
+ },
+};
+
+describe("NameNode HA completion", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ it("requires the Classic HAWQ filespace acknowledgement before clearing state", () => {
+ const flushStateToDb = vi.fn();
+ render(
+ <AppContext.Provider
+ value={
+ {
+ clusterName: "c1",
+ services: [{ ServiceInfo: { service_name: "HAWQ" } }],
+ } as never
+ }
+ >
+ <ServiceContext.Provider
+ value={
+ {
+ serviceModels: {},
+ masterSlaveClientsData: {},
+ } as never
+ }
+ >
+ <EnableHighAvailibilityContext.Provider
+ value={
+ {
+ state: wizardState,
+ dispatch: vi.fn(),
+ flushStateToDb,
+ stepWizardUtilities: {
+ currentStep: { name: "FINALIZE" },
+ },
+ } as never
+ }
+ >
+ <Step9 />
+ </EnableHighAvailibilityContext.Provider>
+ </ServiceContext.Provider>
+ </AppContext.Provider>,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Finish operations" }));
+ fireEvent.click(screen.getByRole("button", { name: "Complete wizard" }));
+
+ expect(mocks.modalShow).toHaveBeenCalledOnce();
+ expect(mocks.modalShow.mock.calls[0][0]).toMatchObject({
+ modalTitle: "Additional Steps Required for HAWQ",
+ });
+ expect(mocks.modalShow.mock.calls[0][0].modalBody).toContain(
+ "HAWQ Filespaces and High Availability Enabled HDFS",
+ );
+ expect(flushStateToDb).not.toHaveBeenCalled();
+ });
+});
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step9.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step9.tsx
index 1efeb981c9..a04c009fb7 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step9.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/Step9.tsx
@@ -16,12 +16,13 @@
* limitations under the License.
*/
-import { useContext, useEffect, useState } from "react";
+import { useContext, useState } from "react";
+import { find, get, map } from "lodash";
+import { t } from "i18next";
import { EnableHighAvailibilityContext } from "./store/context";
import { AppContext } from "../../../../store/context";
import { ServiceContext } from "../../../../store/ServiceContext";
import { getStepData } from "../../../../Utils/Utility";
-import { filter, find, map } from "lodash";
import WizardFooter from "../../../../components/StepWizard/WizardFooter";
import OperationsProgress from "../../../../components/OperationsProgress";
import {
@@ -32,684 +33,346 @@ import {
updateComponent,
} from "../../../../Utils/taskUtils";
import { enableNamenodeSteps } from "./wizardSteps";
-import { t } from "i18next";
import ClusterApi from "../../../../api/clusterApi";
import { HostsApi } from "../../../../api/hostsApi";
import { ActionTypes } from "./store/types";
import useKDCSessionState from "../../../../hooks/useKDCSessionState";
+import {
+ getRangerReconfigureSiteGroups,
+ mergeSavedOperations,
+} from "../haWorkflowUtils";
+import modalManager from "../../../../store/ModalManager";
+import { Alert } from "react-bootstrap";
function Step9() {
- enum COMMANDS {
- startSecondNameNode = "startSecondNameNode",
- installZKFC = "installZKFC",
- startZKFC = "startZKFC",
- reconfigureRanger = "reconfigureRanger",
- reconfigureHBase = "reconfigureHBase",
- reconfigureAMS = "reconfigureAMS",
- reconfigureAccumulo = "reconfigureAccumulo",
- reconfigureHawq = "reconfigureHawq",
- deleteSNameNode = "deleteSNameNode",
- stopHDFS = "stopHDFS",
- startAllServices = "startAllServices",
- }
const {
state,
dispatch,
flushStateToDb,
- stepWizardUtilities: { currentStep, jumpToStep },
+ stepWizardUtilities: { currentStep },
} = useContext(EnableHighAvailibilityContext);
const { clusterName, services } = useContext(AppContext);
- const { serviceModels: allServiceModels }: any = useContext(ServiceContext);
+ const { serviceModels, masterSlaveClientsData }: any =
+ useContext(ServiceContext);
const { getKDCSessionState } = useKDCSessionState(() => {});
const [completionStatus, setCompletionStatus] = useState(false);
- const [stepOperations, setStepOperations] = useState<any>([]);
- const masterComponentHosts = getStepData(
+ const [isCompleting, setIsCompleting] = useState(false);
+ const [completionError, setCompletionError] = useState("");
+ const masterComponentHosts =
+ getStepData(
+ state,
+ enableNamenodeSteps.SELECT_HOSTS,
+ "masterComponentHosts",
+ "enableHighAvailibilitySteps",
+ ) || [];
+ const configData = getStepData(
state,
- "SELECT_HOSTS",
- "masterComponentHosts",
- "enableHighAvailibilitySteps"
+ enableNamenodeSteps.REVIEW,
+ "overridenProperties",
+ "enableHighAvailibilitySteps",
);
-
const selectedServices = map(services, "ServiceInfo.service_name");
- async function saveReconfiguredConfigs(siteNames: any, data: any) {
- const note = t("admin.highAvailability.step9.save.configuration.note");
- const configData = reconfigureSites(siteNames, data, note);
+ const note = t("admin.highAvailability.step9.save.configuration.note");
+
+ const additionalNameNodeHost = find(
+ masterComponentHosts,
+ (hostComponent: any) =>
+ hostComponent.component === "NAMENODE" && !hostComponent.isInstalled,
+ )?.hostName;
+
+ const saveReconfiguredConfigs = async (siteNames: string[]) => {
+ if (!configData?.items) {
+ throw new Error("The reviewed HA configuration snapshot is missing.");
+ }
+ const missingSites = siteNames.filter(
+ (siteName) => !find(configData.items, ["type", siteName]),
+ );
+ if (missingSites.length) {
+ throw new Error(
+ `The reviewed HA configuration is missing: ${missingSites.join(", ")}.`,
+ );
+ }
return await ClusterApi.updateCluster(clusterName, {
Clusters: {
- desired_config: configData,
+ desired_config: reconfigureSites(siteNames, configData, note),
},
});
- }
- function initializeTasks() {
- let id = 0;
- const allOps = [];
- const tasksToRemove = [];
- if (!selectedServices.includes("RANGER")) {
- tasksToRemove.push(COMMANDS.reconfigureRanger);
- }
- if (!selectedServices.includes("HBASE")) {
- tasksToRemove.push(COMMANDS.reconfigureHBase);
- }
- if (!selectedServices.includes("AMBARI_METRICS")) {
- tasksToRemove.push(COMMANDS.reconfigureAMS);
- }
- if (!selectedServices.includes("ACCUMULO")) {
- tasksToRemove.push(COMMANDS.reconfigureAccumulo);
- }
- if (!selectedServices.includes("HAWQ")) {
- tasksToRemove.push(COMMANDS.reconfigureHawq);
- }
+ };
- if (!tasksToRemove.includes(COMMANDS.startSecondNameNode)) {
- allOps.push({
- id: id++,
- label: "Start NameNode",
- skippable: false,
- callback: async () => {
- const hostName = find(masterComponentHosts, (hostComponent: any) => {
- return (
- hostComponent.component === "NAMENODE" &&
- hostComponent.isInstalled === false
- );
- })?.hostName;
- return await updateComponent(
- clusterName,
- "NAMENODE",
- hostName,
- "HDFS",
- "Start",
- 1
- );
- },
- });
- }
+ const initializeTasks = () => {
+ const operations: any[] = [];
+ const nameNodeHosts = masterComponentHosts
+ .filter((hostComponent: any) => hostComponent.component === "NAMENODE")
+ .map((hostComponent: any) => hostComponent.hostName);
- if (!tasksToRemove.includes(COMMANDS.installZKFC)) {
- allOps.push({
- id: id++,
- label: "Install ZKFC",
- skippable: false,
- callback: async () => {
- const hostNames = map(
- filter(masterComponentHosts, ["component", "NAMENODE"]),
- "hostName"
- );
+ operations.push({
+ id: 0,
+ label: "Start Additional NameNode",
+ skippable: false,
+ callback: async () =>
+ await updateComponent(
+ clusterName,
+ "NAMENODE",
+ additionalNameNodeHost,
+ "HDFS",
+ "Start",
+ 1,
+ ),
+ });
+ operations.push({
+ id: 1,
+ label: "Install ZKFC",
+ skippable: false,
+ callback: async () =>
+ await createInstallComponentTask(
+ "ZKFC",
+ nameNodeHosts,
+ "HDFS",
+ clusterName,
+ ["HDFS"],
+ serviceModels.hdfs,
+ getKDCSessionState,
+ ),
+ });
+ operations.push({
+ id: 2,
+ label: "Start ZKFC",
+ skippable: false,
+ callback: async () =>
+ await updateComponent(
+ clusterName,
+ "ZKFC",
+ nameNodeHosts,
+ "HDFS",
+ "Start",
+ 1,
+ ),
+ });
- return await createInstallComponentTask(
- "ZKFC",
- hostNames,
- "HDFS",
- clusterName,
- ["HDFS"],
- allServiceModels["hdfs"],
- getKDCSessionState
- );
- },
- });
- }
- if (!tasksToRemove.includes(COMMANDS.startZKFC)) {
- allOps.push({
- id: id++,
- label: "Start ZKFC",
+ const pxfComponent = find(
+ Object.values(masterSlaveClientsData || {}),
+ ["ServiceComponentInfo.component_name", "PXF"],
+ );
+ const pxfHosts: string[] = map(
+ get(pxfComponent, "host_components", []),
+ (hostComponent: any) => get(hostComponent, "HostRoles.host_name"),
+ ).filter(Boolean);
+ if (
+ selectedServices.includes("PXF") &&
+ additionalNameNodeHost &&
+ !pxfHosts.includes(additionalNameNodeHost)
+ ) {
+ operations.push({
+ id: 3,
+ label: "Install PXF",
skippable: false,
- callback: async () => {
- const hostNames = map(
- filter(masterComponentHosts, ["component", "NAMENODE"]),
- "hostName"
- );
-
- return await updateComponent(
+ callback: async () =>
+ await createInstallComponentTask(
+ "PXF",
+ additionalNameNodeHost,
+ "PXF",
clusterName,
- "ZKFC",
- hostNames,
- "HDFS",
- "Start",
- 1
- );
- },
- });
- }
- if (!tasksToRemove.includes(COMMANDS.reconfigureRanger)) {
- const data = getStepData(
- state,
- enableNamenodeSteps.REVIEW,
- "overridenProperties",
- "enableHighAvailibilitySteps"
- );
- let siteNames = ["ranger-env"];
- const configs: any = [];
- configs.push({
- Clusters: {
- desired_config: reconfigureSites(
- siteNames,
- data,
- t("admin.highAvailability.step9.save.configuration.note")
+ selectedServices,
+ serviceModels.pxf,
+ getKDCSessionState,
),
- },
});
- if (selectedServices.includes("YARN")) {
- siteNames = [];
- const yarnAuditConfig = find(data.items, ["type", "ranger-yarn-audit"]);
- if (yarnAuditConfig) {
- if (
- "xasecure.audit.destination.hdfs.dir" in yarnAuditConfig.properties
- ) {
- siteNames.push("ranger-yarn-audit");
- configs.push({
- Clusters: {
- desired_config: reconfigureSites(
- siteNames,
- data,
- t("admin.highAvailability.step9.save.configuration.note")
- ),
- },
- });
- }
- }
- }
- if (selectedServices.includes("STORM")) {
- const stormPluginConfig = find(data.items, [
- "type",
- "ranger-storm-plugin-properties",
- ]);
- if (stormPluginConfig) {
- if (
- "xasecure.audit.destination.hdfs.dir" in
- stormPluginConfig.properties
- ) {
- siteNames.push("ranger-storm-plugin-properties");
- }
- }
- var stormAuditConfig = find(data.items, ["type", "ranger-storm-audit"]);
- if (stormAuditConfig) {
- if (
- "xasecure.audit.destination.hdfs.dir" in stormAuditConfig.properties
- ) {
- siteNames.push("ranger-storm-audit");
- }
- }
- if (siteNames.length) {
- configs.push({
- Clusters: {
- desired_config: reconfigureSites(
- siteNames,
- data,
- t("admin.highAvailability.step9.save.configuration.note")
- ),
- },
- });
- }
- }
- if (selectedServices.includes("KAFKA")) {
- siteNames = [];
- const kafkaAuditConfig = find(data.items, [
- "type",
- "ranger-kafka-audit",
- ]);
- if (kafkaAuditConfig) {
- if (
- "xasecure.audit.destination.hdfs.dir" in kafkaAuditConfig.properties
- ) {
- siteNames.push("ranger-kafka-audit");
- configs.push({
- Clusters: {
- desired_config: reconfigureSites(
- siteNames,
- data,
- t("admin.highAvailability.step9.save.configuration.note")
- ),
- },
- });
- }
- }
- }
- if (selectedServices.includes("KNOX")) {
- siteNames = [];
- let knoxPluginConfig = find(data.items, [
- "type",
- "ranger-knox-plugin-properties",
- ]);
- if (knoxPluginConfig) {
- if (
- "xasecure.audit.destination.hdfs.dir" in knoxPluginConfig.properties
- ) {
- siteNames.push("ranger-knox-plugin-properties");
- }
- }
- var knoxAuditConfig = data.items.findProperty(
- "type",
- "ranger-knox-audit"
- );
- if (knoxAuditConfig) {
- if (
- "xasecure.audit.destination.hdfs.dir" in knoxAuditConfig.properties
- ) {
- siteNames.push("ranger-knox-audit");
- }
- }
- if (siteNames.length) {
- configs.push({
- Clusters: {
- desired_config: reconfigureSites(
- siteNames,
- data,
- t("admin.highAvailability.step9.save.configuration.note")
- ),
- },
- });
- }
- }
- if (selectedServices.includes("ATLAS")) {
- siteNames = [];
- const atlasAuditConfig = find(data.items, [
- "type",
- "ranger-atlas-audit",
- ]);
- if (atlasAuditConfig) {
- if (
- "xasecure.audit.destination.hdfs.dir" in atlasAuditConfig.properties
- ) {
- siteNames.push("ranger-atlas-audit");
- configs.push({
- Clusters: {
- desired_config: reconfigureSites(
- siteNames,
- data,
- t("admin.highAvailability.step4.save.configuration.note")
- ),
- },
- });
- }
- }
- }
- if (selectedServices.includes("HIVE")) {
- siteNames = [];
- var hivePluginConfig = find(data.items, [
- "type",
- "ranger-hive-plugin-properties",
- ]);
- if (hivePluginConfig) {
- if (
- "xasecure.audit.destination.hdfs.dir" in hivePluginConfig.properties
- ) {
- siteNames.push("ranger-hive-plugin-properties");
- }
- }
- var hiveAuditConfig = find(data.items, ["type", "ranger-hive-audit"]);
- if (hiveAuditConfig) {
- if (
- "xasecure.audit.destination.hdfs.dir" in hiveAuditConfig.properties
- ) {
- siteNames.push("ranger-hive-audit");
- }
- }
- if (siteNames.length) {
- configs.push({
- Clusters: {
- desired_config: reconfigureSites(
- siteNames,
- data,
- t("admin.highAvailability.step9.save.configuration.note")
- ),
- },
- });
- }
- }
- if (selectedServices.includes("RANGER_KMS")) {
- siteNames = [];
- let rangerKMSConfig = find(data.items, ["type", "ranger-kms-audit"]);
- if (rangerKMSConfig) {
- if (
- "xasecure.audit.destination.hdfs.dir" in rangerKMSConfig.properties
- ) {
- siteNames.push("ranger-kms-audit");
- configs.push({
- Clusters: {
- desired_config: reconfigureSites(
- siteNames,
- data,
- t("admin.highAvailability.step9.save.configuration.note")
- ),
- },
- });
- }
- }
- }
+ }
- allOps.push({
- id: id++,
+ if (selectedServices.includes("RANGER")) {
+ operations.push({
+ id: 4,
label: "Reconfigure Ranger",
skippable: false,
callback: async () => {
-
- const data = getStepData(
- state,
- enableNamenodeSteps.REVIEW,
- "overridenProperties",
- "enableHighAvailibilitySteps"
+ const groups = getRangerReconfigureSiteGroups(
+ selectedServices,
+ configData?.items || [],
);
- var siteNames = ["ranger-env"];
- var configs = [];
- configs.push({
+ const configs = groups.map((siteNames) => ({
Clusters: {
- desired_config: reconfigureSites(
- siteNames,
- data,
- t("admin.highAvailability.step9.save.configuration.note")
- ),
+ desired_config: reconfigureSites(siteNames, configData, note),
},
- });
- if (selectedServices.includes("YARN")) {
- siteNames = [];
- const yarnAuditConfig = find(data.items, [
- "type",
- "ranger-yarn-audit",
- ]);
- if (yarnAuditConfig) {
- if (
- "xasecure.audit.destination.hdfs.dir" in
- yarnAuditConfig.properties
- ) {
- siteNames.push("ranger-yarn-audit");
- configs.push({
- Clusters: {
- desired_config: reconfigureSites(
- siteNames,
- data,
- t("admin.highAvailability.step9.save.configuration.note")
- ),
- },
- });
- }
- }
- }
- if (selectedServices.includes("HIVE")) {
- siteNames = [];
- var hivePluginConfig = find(data.items, [
- "type",
- "ranger-hive-plugin-properties",
- ]);
- if (hivePluginConfig) {
- if (
- "xasecure.audit.destination.hdfs.dir" in
- hivePluginConfig.properties
- ) {
- siteNames.push("ranger-hive-plugin-properties");
- }
- }
- var hiveAuditConfig = find(data.items, [
- "type",
- "ranger-hive-audit",
- ]);
- if (hiveAuditConfig) {
- if (
- "xasecure.audit.destination.hdfs.dir" in
- hiveAuditConfig.properties
- ) {
- siteNames.push("ranger-hive-audit");
- }
- }
- if (siteNames.length) {
- configs.push({
- Clusters: {
- desired_config: reconfigureSites(
- siteNames,
- data,
- t("admin.highAvailability.step9.save.configuration.note")
- ),
- },
- });
- }
- }
- if (selectedServices.includes("RANGER_KMS")) {
- siteNames = [];
- let rangerKMSConfig = find(data.items, [
- "type",
- "ranger-kms-audit",
- ]);
- if (rangerKMSConfig) {
- if (
- "xasecure.audit.destination.hdfs.dir" in
- rangerKMSConfig.properties
- ) {
- siteNames.push("ranger-kms-audit");
- configs.push({
- Clusters: {
- desired_config: reconfigureSites(
- siteNames,
- data,
- t("admin.highAvailability.step9.save.configuration.note")
- ),
- },
- });
- }
- }
- }
+ }));
return await ClusterApi.updateCluster(clusterName, configs);
},
});
}
- if (!tasksToRemove.includes(COMMANDS.reconfigureHBase)) {
- const data = getStepData(
- state,
- enableNamenodeSteps.REVIEW,
- "overridenProperties",
- "enableHighAvailibilitySteps"
- );
- const siteNames = ["hbase-site"];
- if (selectedServices.includes("RANGER")) {
- const hbasePluginConfig = find(data.items, [
- "type",
- "ranger-hbase-plugin-properties",
- ]);
- if (hbasePluginConfig) {
- if (
- "xasecure.audit.destination.hdfs.dir" in
- hbasePluginConfig.properties
- ) {
- siteNames.push("ranger-hbase-plugin-properties");
- }
- }
- const hbaseAuditConfig = find(data.items, [
- "type",
- "ranger-hbase-audit",
- ]);
- if (hbaseAuditConfig) {
- if (
- "xasecure.audit.destination.hdfs.dir" in hbaseAuditConfig.properties
- ) {
- siteNames.push("ranger-hbase-audit");
- }
- }
- }
- const configs: any = [];
- configs.push({
- Clusters: {
- desired_config: reconfigureSites(
- siteNames,
- data,
- t("admin.highAvailability.step9.save.configuration.note")
- ),
- },
- });
- allOps.push({
- id: id++,
+
+ if (selectedServices.includes("HBASE")) {
+ operations.push({
+ id: 5,
label: "Reconfigure HBase",
skippable: false,
callback: async () => {
- return await saveReconfiguredConfigs(siteNames, data);
+ const hbaseSites = ["hbase-site"];
+ if (selectedServices.includes("RANGER")) {
+ ["ranger-hbase-plugin-properties", "ranger-hbase-audit"].forEach(
+ (siteName) => {
+ const site = find(configData?.items, ["type", siteName]);
+ if (
+ site?.properties &&
+ Object.prototype.hasOwnProperty.call(
+ site.properties,
+ "xasecure.audit.destination.hdfs.dir",
+ )
+ ) {
+ hbaseSites.push(siteName);
+ }
+ },
+ );
+ }
+ return await saveReconfiguredConfigs(hbaseSites);
},
});
}
- if (!tasksToRemove.includes(COMMANDS.reconfigureAMS)) {
- const data = getStepData(
- state,
- enableNamenodeSteps.REVIEW,
- "overridenProperties",
- "enableHighAvailibilitySteps"
- );
- allOps.push({
- id: id++,
- label: "Reconfigure AMS",
+ if (selectedServices.includes("AMBARI_METRICS")) {
+ operations.push({
+ id: 6,
+ label: "Reconfigure AMS for NameNode HA",
skippable: false,
- callback: async () => {
- return await saveReconfiguredConfigs(["ams-hbase-site"], data);
- },
+ callback: async () =>
+ await saveReconfiguredConfigs(["ams-hbase-site"]),
});
}
- if (!tasksToRemove.includes(COMMANDS.reconfigureAccumulo)) {
- const data = getStepData(
- state,
- enableNamenodeSteps.REVIEW,
- "overridenProperties",
- "enableHighAvailibilitySteps"
- );
- allOps.push({
- id: id++,
+ if (selectedServices.includes("ACCUMULO")) {
+ operations.push({
+ id: 7,
label: "Reconfigure Accumulo",
skippable: false,
- callback: async () => {
- return await saveReconfiguredConfigs(["accumulo-site"], data);
- },
- });
- }
- if (!tasksToRemove.includes(COMMANDS.reconfigureHawq)) {
- const data = getStepData(
- state,
- enableNamenodeSteps.REVIEW,
- "overridenProperties",
- "enableHighAvailibilitySteps"
- );
- allOps.push({
- id: id++,
- label: "Reconfigure Hawq",
- skippable: false,
- callback: async () => {
- return await saveReconfiguredConfigs(["hawq-site"], data);
- },
+ callback: async () => await saveReconfiguredConfigs(["accumulo-site"]),
});
}
- if (!tasksToRemove.includes(COMMANDS.deleteSNameNode)) {
- const hostName = find(masterComponentHosts, (hostComponent: any) => {
- return (
- hostComponent.component === "SECONDARY_NAMENODE" &&
- hostComponent.isInstalled === true
- );
- })?.hostName;
- allOps.push({
- id: id++,
- label: "Delete Secondary NameNode",
+ if (selectedServices.includes("HAWQ")) {
+ operations.push({
+ id: 8,
+ label: "Reconfigure HAWQ",
skippable: false,
- callback: async () => {
- return await HostsApi.deleteHostComponent(
- clusterName,
- hostName,
- "SECONDARY_NAMENODE"
- );
- },
- });
- }
- if (!tasksToRemove.includes(COMMANDS.stopHDFS)) {
- allOps.push({
- id: id++,
- label: "Stop HDFS",
- skippable: false,
- callback: async () => {
- return await stopServices(
- clusterName,
- ["HDFS"],
- true,
- false,
- selectedServices
- );
- },
- });
- }
- if (!tasksToRemove.includes(COMMANDS.startAllServices)) {
- allOps.push({
- id: id++,
- label: "Start All Services",
- skippable: false,
- callback: async () => {
- return await startServices(clusterName, false, [], false);
- },
+ callback: async () =>
+ await saveReconfiguredConfigs(["hawq-site", "hdfs-client"]),
});
}
- return allOps;
- }
-
- useEffect(() => {
- initializeTasks();
- }, []);
-
-
+ const secondaryNameNodeHost = find(
+ masterComponentHosts,
+ (hostComponent: any) =>
+ hostComponent.component === "SECONDARY_NAMENODE" &&
+ hostComponent.isInstalled,
+ )?.hostName;
+ operations.push({
+ id: 9,
+ label: "Delete Secondary NameNode",
+ skippable: false,
+ callback: async () =>
+ await HostsApi.deleteHostComponent(
+ clusterName,
+ secondaryNameNodeHost,
+ "SECONDARY_NAMENODE",
+ ),
+ });
+ operations.push({
+ id: 10,
+ label: "Stop HDFS",
+ skippable: false,
+ callback: async () =>
+ await stopServices(
+ clusterName,
+ ["HDFS"],
+ true,
+ false,
+ selectedServices,
+ ),
+ });
+ operations.push({
+ id: 11,
+ label: "Start All Services",
+ skippable: false,
+ callback: async () => await startServices(clusterName, false, [], false),
+ });
+ return operations;
+ };
const savedOperationsState = getStepData(
state,
enableNamenodeSteps.FINALIZE,
"operationsState",
- "enableHighAvailibilitySteps"
+ "enableHighAvailibilitySteps",
+ );
+ const [stepOperations] = useState(() =>
+ mergeSavedOperations(initializeTasks(), savedOperationsState),
);
- useEffect(() => {
- const initialOperations = initializeTasks();
- const operations = (() => {
- if (savedOperationsState && Array.isArray(savedOperationsState)) {
- return initialOperations.map((originalOp) => {
- const savedOp = savedOperationsState.find(
- (saved: any) => saved.id === originalOp.id
- );
- return savedOp
- ? { ...originalOp, ...savedOp, callback: originalOp.callback }
- : originalOp;
- });
- }
-
- return initialOperations;
- })();
- setStepOperations(operations);
- }, [JSON.stringify(savedOperationsState)]);
-
- if (!stepOperations || stepOperations.length === 0) {
- return <div>Loading...</div>;
- }
+ const completeWizard = async () => {
+ setIsCompleting(true);
+ setCompletionError("");
+ try {
+ await flushStateToDb("complete");
+ window.location.href = "#/main/services/HDFS/summary";
+ window.location.reload();
+ } catch (error: any) {
+ setCompletionError(
+ error?.response?.data?.message ||
+ error?.message ||
+ "Ambari could not clear the NameNode HA workflow checkpoint.",
+ );
+ setIsCompleting(false);
+ }
+ };
+ const handleComplete = () => {
+ if (!selectedServices.includes("HAWQ")) {
+ void completeWizard();
+ return;
+ }
+ modalManager.show({
+ modalTitle: t(
+ "admin.highAvailability.wizard.step9.hawq.confirmPopup.header",
+ ),
+ modalBody: t(
+ "admin.highAvailability.wizard.step9.hawq.confirmPopup.body",
+ ),
+ onClose: () => {},
+ successCallback: () => {
+ modalManager.hide();
+ void completeWizard();
+ },
+ options: {
+ buttonSize: "sm",
+ cancelableViaIcon: true,
+ cancelableViaBtn: false,
+ okButtonVariant: "primary",
+ },
+ });
+ };
return (
<>
+ {completionError ? <Alert variant="danger">{completionError}</Alert> : null}
<OperationsProgress
title=""
description=""
setCompletionStatus={setCompletionStatus}
operations={stepOperations as any}
- dispatch={(operationsState: any) => {
+ dispatch={async (operationsState: any) => {
dispatch({
type: ActionTypes.STORE_INFORMATION,
payload: {
step: currentStep.name,
- data: {
- operationsState,
- },
+ data: { operationsState },
},
});
+ await flushStateToDb();
}}
/>
<WizardFooter
step={currentStep}
- isNextEnabled={completionStatus}
- onNext={() => {
- flushStateToDb("cancel");
- window.location.href = "#/main/services/HDFS/summary";
- window.location.reload();
- }}
- onBack={() => {
- jumpToStep(8);
- flushStateToDb("back");
- }}
+ isNextEnabled={completionStatus && !isCompleting}
+ onNext={handleComplete}
+ onBack={() => {}}
onCancel={() => {
- flushStateToDb("cancel");
+ void flushStateToDb("cancel");
}}
+ cancelConfirmationBody="NameNode HA changes have started. Exiting preserves the workflow checkpoint so the operation can be resumed. Complete the documented manual recovery before making further HDFS topology changes."
/>
</>
);
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/ValidateEnablement.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/ValidateEnablement.tsx
index ac5925274b..7f1fbfe760 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/ValidateEnablement.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/ValidateEnablement.tsx
@@ -30,9 +30,11 @@ import ClusterApi from "../../../../api/clusterApi";
import { LocalStorageOps } from "../../../../Utils/LocalStorageOps";
import { AppContext } from "../../../../store/context";
import { ClusterProgressStatus } from "../../../../constants";
+import { ServiceContext } from "../../../../store/ServiceContext";
function ValidateEnablement() {
- const {services, clusterState}=useContext(AppContext);
+ const { allServiceModels } = useContext(ServiceContext);
+ const { services, clusterState, allHostNames } = useContext(AppContext);
const { hostComponents: serviceHostComponents, serviceComponents } =
useHostComponents(map(services, "ServiceInfo.service_name"));
const stepWizardUtilities = useStepWizard(wizardSteps, 0);
@@ -112,6 +114,12 @@ function ValidateEnablement() {
) {
messages.push(t("admin.highAvailability.error.zooKeeperNum"));
}
+ if (allHostNames.length < 3) {
+ messages.push("NameNode HA requires at least three registered hosts.");
+ }
+ if (allServiceModels?.hdfs?.isNameNodeHaEnabled) {
+ messages.push("NameNode HA is already enabled for HDFS.");
+ }
if (
some(filter(hostComponents, ["isMaster", true]), [
"maintenance_state",
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/store/context.test.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/store/context.test.tsx
new file mode 100644
index 0000000000..02cae5d284
--- /dev/null
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/store/context.test.tsx
@@ -0,0 +1,117 @@
+/**
+ * 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 { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { useContext } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ getPersistData: vi.fn(),
+ postPersistData: vi.fn(),
+}));
+
+vi.mock("../../../../../api/clusterApi", () => ({
+ default: {
+ getPersistData: mocks.getPersistData,
+ postPersistData: mocks.postPersistData,
+ },
+}));
+vi.mock("../../../../../hooks/useAuth", () => ({
+ default: () => ({ user: { user_name: "ha-owner" } }),
+}));
+
+import {
+ EnableHighAvailibilityContext,
+ EnableHighAvailibilityProvider,
+} from "./context";
+
+const jumpToStep = vi.fn();
+const wizardUtilities = {
+ activeStep: 0,
+ jumpToStep,
+ wizardSteps: {
+ 0: { name: "GET_STARTED" },
+ 4: { name: "CONFIGURE_COMPONENTS" },
+ },
+};
+
+function StateProbe() {
+ const { state, flushStateToDb } = useContext(EnableHighAvailibilityContext);
+ return (
+ <>
+ <div data-testid="state">{JSON.stringify(state)}</div>
+ <button onClick={() => void flushStateToDb()}>Persist</button>
+ </>
+ );
+}
+
+function renderProvider() {
+ return render(
+ <EnableHighAvailibilityProvider stepWizardUtilities={wizardUtilities}>
+ <StateProbe />
+ </EnableHighAvailibilityProvider>,
+ );
+}
+
+describe("NameNode HA workflow hydration", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.postPersistData.mockResolvedValue({});
+ });
+
+ afterEach(() => cleanup());
+
+ it("treats a missing persisted value as a fresh workflow", async () => {
+ mocks.getPersistData.mockRejectedValue({ response: { status: 404 } });
+ renderProvider();
+
+ expect(await screen.findByTestId("state")).toBeTruthy();
+ expect(jumpToStep).toHaveBeenCalledWith(0, true);
+ expect(mocks.postPersistData).not.toHaveBeenCalled();
+ });
+
+ it("restores a string-valued checkpoint before rendering children", async () => {
+ mocks.getPersistData.mockResolvedValue(
+ JSON.stringify({
+ activeStep: "CONFIGURE_COMPONENTS",
+ enableHighAvailibilitySteps: {
+ REVIEW: { step: "REVIEW", data: { nameserviceId: "nameservice1" } },
+ },
+ }),
+ );
+ renderProvider();
+
+ await waitFor(() =>
+ expect(jumpToStep).toHaveBeenCalledWith(4, true),
+ );
+ expect(screen.getByTestId("state").textContent).toContain("nameservice1");
+ expect(mocks.postPersistData).not.toHaveBeenCalled();
+ });
+
+ it("persists the workflow owner with the recoverable checkpoint", async () => {
+ mocks.getPersistData.mockRejectedValue({ response: { status: 404 } });
+ renderProvider();
+
+ fireEvent.click(await screen.findByRole("button", { name: "Persist" }));
+
+ await waitFor(() => expect(mocks.postPersistData).toHaveBeenCalledOnce());
+ expect(mocks.postPersistData.mock.calls[0][0]["wizard-data"]).toBe(
+ JSON.stringify({ userName: "ha-owner" }),
+ );
+ });
+});
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/store/context.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/store/context.tsx
index 1558a43290..d0054f2884 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/store/context.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/store/context.tsx
@@ -24,12 +24,19 @@ import React, {
useRef,
useState,
} from "react";
+import { Alert, Button } from "react-bootstrap";
+import { get, isEmpty } from "lodash";
import { State, Action, ActionTypes } from "./types";
import { reducer, initialState } from "./reducer";
import ClusterApi from "../../../../../api/clusterApi";
-import { get, isEmpty } from "lodash";
import { ClusterProgressStatus } from "../../../../../constants";
import modalManager from "../../../../../store/ModalManager";
+import Spinner from "../../../../../components/Spinner";
+import {
+ parsePersistedValue,
+ persistedPayload,
+} from "../../../../../Utils/persistedSettings";
+import useAuth from "../../../../../hooks/useAuth";
interface EnableHighAvailibilityContextProps {
state: State;
@@ -49,132 +56,179 @@ export const EnableHighAvailibilityProvider: React.FC<{
stepWizardUtilities: any;
children: React.ReactNode;
}> = ({ stepWizardUtilities, children }) => {
- const [state, dispatch] = useReducer(reducer, initialState);
- const [currStepData, setCurrStepData] = useState({});
+ const { user } = useAuth();
+ const workflowOwner = user?.user_name || "";
+ const [state, reducerDispatch] = useReducer(reducer, initialState);
+ const [isHydrated, setIsHydrated] = useState(false);
+ const [initializationError, setInitializationError] = useState("");
+ const [retryCount, setRetryCount] = useState(0);
+ const stateRef = useRef<State>(initialState);
+ const currStepDataRef = useRef<Record<string, any>>({});
+ const persistenceQueue = useRef<Promise<void>>(Promise.resolve());
- const isDataPersisted = useRef(false);
+ const dispatch: Dispatch<Action> = (action) => {
+ stateRef.current = reducer(stateRef.current, action);
+ reducerDispatch(action);
+ };
- useEffect(() => {
- syncUserPersistedData();
- }, []);
+ const queuePersistence = (operation: () => Promise<void>) => {
+ const nextOperation = persistenceQueue.current
+ .catch(() => undefined)
+ .then(operation);
+ persistenceQueue.current = nextOperation.catch(() => undefined);
+ return nextOperation;
+ };
useEffect(() => {
- if (isDataPersisted.current) {
- flushCurrentData();
- }
- }, [state.enableHighAvailibilitySteps, currStepData]);
+ void syncUserPersistedData();
+ }, [retryCount]);
async function syncUserPersistedData() {
+ setIsHydrated(false);
+ setInitializationError("");
try {
- const persistedData = await ClusterApi.getPersistData(
- "HIGH_AVAILIBILITY_NAMENODE"
- );
+ let response: unknown = initialState;
+ try {
+ response = await ClusterApi.getPersistData(
+ "HIGH_AVAILIBILITY_NAMENODE",
+ );
+ } catch (error: any) {
+ if (error?.response?.status !== 404 && error?.status !== 404) {
+ throw error;
+ }
+ }
+ const persistedData = parsePersistedValue(response, initialState);
if (!isEmpty(get(persistedData, "enableHighAvailibilitySteps", {}))) {
- dispatch({
- type: ActionTypes.SYNC_STATE,
- payload: persistedData,
- });
+ dispatch({ type: ActionTypes.SYNC_STATE, payload: persistedData });
}
- if (get(persistedData, "activeStep", "")) {
- try {
- const activeStepName = get(persistedData, "activeStep");
- setCurrStepData({
- progressStatus: ClusterProgressStatus.ENABLING_NAMENODE_HA,
- stepName: activeStepName,
- });
- let activeStepNumber = Object.keys(
- stepWizardUtilities.wizardSteps
- ).find((stepName) => {
- return (
- stepWizardUtilities.wizardSteps?.[stepName]?.name ===
- activeStepName
- );
- });
+ const activeStepName = get(persistedData, "activeStep", "");
+ if (activeStepName) {
+ const restoredStepData = {
+ progressStatus: ClusterProgressStatus.ENABLING_NAMENODE_HA,
+ stepName: activeStepName,
+ };
+ currStepDataRef.current = restoredStepData;
+ const activeStepNumber = Object.keys(
+ stepWizardUtilities.wizardSteps,
+ ).find(
+ (stepName) =>
+ stepWizardUtilities.wizardSteps[stepName]?.name === activeStepName,
+ );
+ if (activeStepNumber !== undefined) {
stepWizardUtilities.jumpToStep(Number(activeStepNumber), true);
- } catch (err) {
- console.error("Error while jumping to step", err);
}
} else {
stepWizardUtilities.jumpToStep(0, true);
}
- } finally {
- isDataPersisted.current = true;
+ setIsHydrated(true);
+ } catch (error: any) {
+ setInitializationError(
+ error?.response?.data?.message ||
+ "Ambari could not restore the NameNode HA workflow.",
+ );
}
}
- async function flushCurrentData() {
+ async function flushCurrentData(
+ stateSnapshot: State = stateRef.current,
+ stepSnapshot: Record<string, any> = currStepDataRef.current,
+ ) {
await ClusterApi.postPersistData(
- JSON.stringify({
- HIGH_AVAILIBILITY_NAMENODE: JSON.stringify({
- ...state,
- activeStep: get(currStepData, "stepName", ""),
- }),
- CLUSTER_STATE: JSON.stringify(currStepData),
- })
+ persistedPayload({
+ HIGH_AVAILIBILITY_NAMENODE: {
+ ...stateSnapshot,
+ activeStep: get(stepSnapshot, "stepName", ""),
+ },
+ CLUSTER_STATE: stepSnapshot,
+ "wizard-data": { userName: workflowOwner },
+ }),
);
}
- function flushOnCancel() {
- ClusterApi.postPersistData(
- JSON.stringify({
- HIGH_AVAILIBILITY_NAMENODE: JSON.stringify(initialState),
- CLUSTER_STATE: JSON.stringify({}),
- })
+ async function clearPersistedState() {
+ await queuePersistence(() =>
+ ClusterApi.postPersistData(
+ persistedPayload({
+ HIGH_AVAILIBILITY_NAMENODE: initialState,
+ CLUSTER_STATE: {},
+ "wizard-data": {},
+ }),
+ ),
);
+ }
+
+ async function flushOnCancel() {
+ if (stepWizardUtilities.activeStep >= 4) {
+ await queuePersistence(() => flushCurrentData());
+ } else {
+ await clearPersistedState();
+ }
modalManager.hide();
window.location.href = "/#/main/services/HDFS/summary";
}
- async function flushOnStepChange(nextStep: number) {
- if (nextStep >= 0) {
- let nextStepDetails = stepWizardUtilities.wizardSteps?.[nextStep];
- if (nextStepDetails?.keysToRemove) {
- nextStepDetails.keysToRemove.forEach((key: string) => {
- if (state?.enableHighAvailibilitySteps?.[key]) {
- dispatch({
- type: ActionTypes.REMOVE_KEY,
- payload: { key },
- });
- }
- });
- }
- setCurrStepData({
- progressStatus: ClusterProgressStatus.ENABLING_NAMENODE_HA,
- stepName: stepWizardUtilities?.wizardSteps?.[nextStep]?.name,
+ async function flushOnStepChange(nextStep: number | undefined) {
+ if (nextStep === undefined || nextStep < 0) return;
+ const nextStepDetails = stepWizardUtilities.wizardSteps[nextStep];
+ let nextState = stateRef.current;
+ nextStepDetails?.keysToRemove?.forEach((key: string) => {
+ nextState = reducer(nextState, {
+ type: ActionTypes.REMOVE_KEY,
+ payload: { key },
});
+ });
+ if (nextState !== stateRef.current) {
+ dispatch({ type: ActionTypes.SYNC_STATE, payload: nextState });
}
+ const nextStepData = {
+ progressStatus: ClusterProgressStatus.ENABLING_NAMENODE_HA,
+ stepName: nextStepDetails?.name,
+ };
+ currStepDataRef.current = nextStepData;
+ await queuePersistence(() => flushCurrentData(nextState, nextStepData));
}
- function flushStateToDb(
+ async function flushStateToDb(
operation: string = "default",
- jumpStep: number = -1
+ jumpStep: number = -1,
) {
- let activeStep = Object.keys(stepWizardUtilities.wizardSteps).find(
- (stepName) => {
- return (
- stepWizardUtilities.wizardSteps?.[stepName]?.name ===
- stepWizardUtilities.currentStep.name
- );
- }
- );
switch (operation) {
case "cancel":
- flushOnCancel();
+ await flushOnCancel();
+ break;
+ case "complete":
+ await clearPersistedState();
break;
case "back":
- flushOnStepChange(Number(activeStep) - 1);
+ await flushOnStepChange(stepWizardUtilities.prevStepNumber);
break;
case "next":
- flushOnStepChange(Number(activeStep) + 1);
+ await flushOnStepChange(stepWizardUtilities.nextStepNumber);
break;
case "jump":
- flushOnStepChange(jumpStep);
+ await flushOnStepChange(jumpStep);
break;
default:
- flushCurrentData();
+ await queuePersistence(() => flushCurrentData());
}
}
+ if (initializationError) {
+ return (
+ <Alert variant="danger">
+ {initializationError}
+ <Button
+ size="sm"
+ className="ms-3"
+ onClick={() => setRetryCount((value) => value + 1)}
+ >
+ Retry
+ </Button>
+ </Alert>
+ );
+ }
+ if (!isHydrated) return <Spinner />;
+
return (
<EnableHighAvailibilityContext.Provider
value={{ state, dispatch, stepWizardUtilities, flushStateToDb }}
diff --git a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/wizardSteps.tsx b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/wizardSteps.tsx
index 9b551b2fe9..dc264f9798 100644
--- a/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/wizardSteps.tsx
+++ b/ambari-web/latest/src/screens/Services/highAvailibility/nameNode/wizardSteps.tsx
@@ -109,7 +109,7 @@ export default {
label: "Configure Components",
completed: false,
Component: <Step5 />,
- canGoBack: true,
+ canGoBack: false,
isNextEnabled: true,
name: enableNamenodeSteps.CONFIGURE_COMPONENTS,
keysToRemove: [
@@ -123,7 +123,7 @@ export default {
label: "Initialize JournalNodes",
completed: false,
Component: <Step6 />,
- canGoBack: true,
+ canGoBack: false,
isNextEnabled: true,
name: enableNamenodeSteps.INITIALIZE_JOUNRALNODES,
keysToRemove: [
@@ -136,7 +136,7 @@ export default {
label: "Start Components",
completed: false,
Component: <Step7 />,
- canGoBack: true,
+ canGoBack: false,
isNextEnabled: false,
name: enableNamenodeSteps.START_COMPONENTS,
keysToRemove: [
@@ -148,7 +148,7 @@ export default {
label: "Initilize Metadata",
completed: false,
Component: <Step8 />,
- canGoBack: true,
+ canGoBack: false,
isNextEnabled: false,
name: enableNamenodeSteps.INITIALIZE_METADATA,
keysToRemove: [enableNamenodeSteps.FINALIZE],
@@ -157,7 +157,7 @@ export default {
label: "Finalize HA Setup",
completed: false,
Component: <Step9 />,
- canGoBack: true,
+ canGoBack: false,
isNextEnabled: false,
name: enableNamenodeSteps.FINALIZE,
},
diff --git a/ambari-web/latest/src/types/StepWizard.ts b/ambari-web/latest/src/types/StepWizard.ts
index 95dcf46fde..ea7be3d691 100644
--- a/ambari-web/latest/src/types/StepWizard.ts
+++ b/ambari-web/latest/src/types/StepWizard.ts
@@ -19,11 +19,12 @@
import { ReactNode } from "react";
export interface Step {
- label: String;
- completed: Boolean;
+ label: string;
+ completed: boolean;
Component: ReactNode;
- canGoBack: Boolean;
- isNextEnabled: Boolean;
- onNext?:any;
- nextLabel?:String;
+ canGoBack: boolean;
+ isNextEnabled: boolean;
+ hidden?: boolean;
+ onNext?: any;
+ nextLabel?: string;
}
diff --git a/docs/frontend-refactor/react-current/09-namenode-journalnode-ha-gap.md b/docs/frontend-refactor/react-current/09-namenode-journalnode-ha-gap.md
new file mode 100644
index 0000000000..526e30386c
--- /dev/null
+++ b/docs/frontend-refactor/react-current/09-namenode-journalnode-ha-gap.md
@@ -0,0 +1,377 @@
+<!---
+ 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.
+-->
+
+# React NameNode HA and JournalNode Management Gap Analysis
+
+## Scope and Audit Result
+
+This document compares the executable Classic Ember implementation, the Ambari REST contracts, and the React implementation for Module 09. The audit covers all 185 stable IDs in `09-namenode-journalnode-ha.md`. Metrics product pages and global metric refresh are excluded. The checkpoint and JournalNode formatted fields below `metrics/dfs/...` remain in scope because they are installation safety gates.
+
+Status meanings:
+
+| Status | Meaning |
+| --- | --- |
+| `MATCH` | React implements the reachable Classic behavior and contract. |
+| `PARTIAL` | The main path exists, but a condition, branch, request, recovery path, or user-visible behavior is incomplete. |
+| `DIFFERENT` | React implements behavior that conflicts with the Classic contract or would produce an incorrect server-side result. |
+| `MISSING` | No usable React implementation exists. |
+| `NOT_APPLICABLE` | Classic code is unreachable, placeholder-only, or explicitly excluded from parity. |
+
+The confirmed migration gaps in the normal NNHA and Manage JournalNodes paths have been implemented. The shared progress runner now persists a task checkpoint before executing or advancing, resumes saved request IDs after refresh, treats every terminal failure state consistently, and fails closed on install or KDC prerequisites. The remaining product gap is the Stack Upgrade custom-check shortcut into NNHA. Stack-specific combinations, real Kerberos credential stores, Windows JournalNode [...]
+
+Classic automatic rollback and Disable NNHA remain explicitly out of scope because the executable Classic tree contains only placeholder, unreachable, or incorrectly routed implementations. Metrics product pages remain excluded.
+
+## State Machines
+
+### Enable NameNode HA
+
+| State | Forward condition and side effects | Back/exit and recovery | React result |
+| --- | --- | --- | --- |
+| Entry | `SERVICE.ENABLE_HA`, persisted-data capability, HDFS HA support, HA disabled, at least 3 hosts, at least 3 ZooKeeper Servers, started current NN, and no HDFS master maintenance | Invalid entry remains outside the wizard; a valid persisted owner resumes its saved step | Menu and direct route enforce service and persistence authorization; validation enforces host/ZooKeeper/NN/maintenance/HA state. `wizard-data.userName` protects another user's active workflow. Exact stack combina [...]
+| Step 1 Get Started | Valid 1-63 character Nameservice ID; save ID and clear later topology/config snapshots | No Back; early Cancel clears pre-change state and owner | React restores the ID, loads `hadoop-env/hdfs_user`, shows HBase/conditional HAWQ warnings, invalidates downstream keys, and awaits persistence before advancing. |
+| Step 2 Select Hosts | Exactly one additional NN and at least 3 JNs; unique valid hosts; all selected JNs are retained | Back to Step 1 and invalidate Review/later state | Assignment and Advisor calls are retained; Next is tied to the complete unique NN/JN selection and the saved snapshot is persisted before navigation. |
+| Step 3 Review | Load exact desired tags/config versions; generate HA and dependency changes; only JN edits directory is editable; save immutable snapshot | Back to Step 2; load/generation error stays on Review with Retry | React clones descriptors/state for every build, exposes tag/topology/generation errors, writes the editable directory into the submitted `hdfs-site` snapshot, and preserves exact desired versions. |
+| Step 4 Checkpoint | Poll current NN; require Safemode and transaction delta <= 1; then pass KDC session gate | No Back; early Cancel clears; parse/GET/KDC failures remain recoverable | Malformed metrics and transport failures are visible without enabling Next. Started state remains the Classic informational warning, and KDC errors stay on the step. |
+| Step 5 Configure | Strictly stop all, install NN, install all JNs, save HDFS/Ranger, install HDFS clients, start all JNs, disable SNN | No Back; command Retry; critical exit preserves owner/checkpoint | The install chain stops on create/register/install/KDC failure, secure clusters reload current tags before overlay, every request/status is checkpointed, and Retry targets the failed operation. |
+| Step 6 Initialize JNs | Poll every selected JN as a complete set; all must report this nameservice formatted | No Back; critical exit preserves owner/checkpoint | Next starts disabled and requires a host-keyed response from every selected JN, including 4/5-node sets; malformed/missing results remain visible and polling retries. |
+| Step 7 Start Components | Strictly start ZK, conditional Infra/MySQL/Ranger, then current NN | No Back; command Retry and critical exit | React reloads authoritative component topology, builds only applicable dependency tasks, restores saved operations, and offers Retry when topology loading fails. |
+| Step 8 Initialize Metadata | Manual `formatZK` and `bootstrapStandby`; KDC gate; explicit completion confirmation | No Back; critical exit/rollback boundary | Both commands use the configured HDFS user. KDC validation precedes an explicit manual-completion confirmation and persistence failure remains on the step. |
+| Step 9 Finalize | Strictly start new NN, install/start ZKFC, conditional PXF/dependency configs, delete SNN, stop HDFS, start all | Complete clears owner only after persistence; Retry resumes first failure; no Back | PXF, complete Ranger groups, HBase/AMS/Accumulo, HAWQ plus `hdfs-client`, SNN deletion, HDFS stop, and start-all are ordered. HAWQ acknowledgement and completion-persistence errors are visible. |
+
+### Manage JournalNodes
+
+| State | Forward condition and side effects | Back/exit and recovery | React result |
+| --- | --- | --- | --- |
+| Entry | Unified mutation/persistence policy; HA topology; Active and Standby NN labels; action cardinality permits add or delete | Invalid direct route is blocked; saved owner resumes | Service action and route enforce feature flag, persisted-data permission, Classic's service-permission alternatives, HA/cardinality, and Active/Standby validation. Host Details add/delete routes to the same wizard under its host permission; add uses the KDC gate. |
+| Step 1 Assign | Start with current JNs; final set >= 3; maximum `min(stack/hosts, existing*2-1)`; no-op disabled | No Back; early Cancel clears pre-change state | Current JNs are retained without an unsolicited add. Next requires a real change, minimum three, and the Classic host/existing-count maximum. |
+| Step 2 Review | Compute exact add/delete sets; non-Federation or every Federation shared-edits key; save deduplicated snapshot | Back to Step 1; delete-only goes to Step 4 | React builds exact delta sets and fresh one-per-namespace descriptors. Delete-only hides Steps 3/5, renumbers the navigation, persists the mode, and jumps to Step 4. |
+| Step 3 Save Namespace | One command set per namespace; exact namespace-to-check-host response set must pass | Back to Review; omitted in delete-only | Federation commands include `-fs hdfs://<ns>` and the aggregate endpoint must return exactly one response for every selected host; incomplete/duplicate/malformed sets cannot pass. |
+| Step 4 Add/Remove | Strictly stop standby NN, stop all, install adds, await all deletes, save HDFS, install clients | No Back; command Retry; critical exit preserves owner/checkpoint | Installs and config saves fail closed, all deletes are awaited with missing resources idempotent, operations restore after refresh, and persistence precedes each command. |
+| Step 5 Copy Directories | Show deduplicated directories for one/all namespaces; manual completion | No Back; omitted in delete-only | React requires every namespace directory property, deduplicates displayed paths, exposes load/missing-property Retry, and uses manual acknowledgement. |
+| Step 6 Start JNs | Reload final server topology, then start exactly the final JN set | No Back; command Retry | An authoritative component GET must return at least three final JNs before the restored start operation can execute; loading failure exposes Retry. |
+| Step 7 Start All | Start all services and poll; clear owner only after durable completion | Finish returns to HDFS Summary | Saved operation status resumes. Finish awaits owner/workflow clearing, reports persistence failure, and navigates only after success. |
+
+## Feature ID Status
+
+### Scope and Entry
+
+| Feature ID | Status | Classic versus current React |
+| --- | --- | --- |
+| NNHA-SCOPE-001 | `MATCH` | A modal nine-step route exists and stores step state. |
+| NNHA-SCOPE-002 | `MATCH` | Manage JN has seven normal steps and five visible, renumbered delete-only steps. |
+| NNHA-SCOPE-003 | `MATCH` | Product Metrics is absent while checkpoint/JN formatted fields are read. |
+| NNHA-SCOPE-004 | `MATCH` | AMS is limited to `ams-hbase-site` migration/submission. |
+| NNHA-SCOPE-005 | `MATCH` | This audit uses executable routes, operations, API helpers, and tests rather than inventory hits. |
+| NNHA-ENTRY-001 | `MATCH` | The HDFS action uses its `HA_MODE` service model, `SERVICE.ENABLE_HA`, persisted-data permission, HA-disabled state, and validation disable reasons. |
+| NNHA-ENTRY-002 | `MATCH` | React exposes no Disable NNHA entry. |
+| NNHA-ENTRY-003 | `MISSING` | The upgrade custom-check entry does not route into the React wizard. |
+| NNHA-ENTRY-004 | `MATCH` | Validation waits for component data, requires a started NN, and fails gracefully when the NN is absent. |
+| NNHA-ENTRY-005 | `MATCH` | React requires three ZooKeeper Server components and does not require STARTED state. |
+| NNHA-ENTRY-006 | `MATCH` | Both entry validation and assignment require the three-host minimum. |
+| NNHA-ENTRY-007 | `MATCH` | Explicit and implied master maintenance states block entry. |
+| NNHA-ENTRY-008 | `MATCH` | Direct routes enforce service/persistence authorization and wizard ownership, then run the same host, HA, NN, ZooKeeper, and maintenance validation. |
+
+### NameNode HA Steps and Configuration
+
+| Feature ID | Status | Classic versus current React |
+| --- | --- | --- |
+| NNHA-STEP1-001 | `MATCH` | ID validation, restored input, configured HDFS user, downstream invalidation, and awaited save are implemented. |
+| NNHA-STEP1-002 | `MATCH` | HBase warning is informational. |
+| NNHA-STEP1-003 | `MATCH` | HAWQ installations display the Classic filespace warning at entry and completion. |
+| NNHA-STEP2-001 | `MATCH` | One additional NN and at least three JNs are seeded through Advisor; readiness and complete assignment validity gate Next. |
+| NNHA-STEP2-002 | `MATCH` | Duplicate/empty/invalid NN or JN assignment keeps Next disabled. |
+| NNHA-STEP2-003 | `MATCH` | JN Add uses stack cardinality and host count rather than a hard maximum of three. |
+| NNHA-STEP2-004 | `MATCH` | Back removes downstream normal-flow snapshots and persistence completes before navigation; placeholder rollback data is not retained. |
+| NNHA-STEP3-001 | `MATCH` | Review displays the exact hosts/configs and exposes topology, tag, fetch, and build failures with Retry. |
+| NNHA-STEP3-002 | `MATCH` | Only `dfs.journalnode.edits.dir` is editable. |
+| NNHA-STEP3-003 | `MATCH` | Required dependency sites are loaded by exact desired tags; fresh cloned descriptors prevent cross-load mutation. |
+| NNHA-STEP3-004 | `MATCH` | The immutable submitted snapshot, including edited JN directory value, is serialized before navigation. |
+| NNHA-STEP4-001 | `MATCH` | Commands and repeated checkpoint GET are present. |
+| NNHA-STEP4-002 | `MATCH` | Safemode and transaction delta form the Next gate. |
+| NNHA-STEP4-003 | `MATCH` | A non-started NN displays the Classic warning but, as in Classic, is not added to the checkpoint Next calculation. |
+| NNHA-STEP4-004 | `MATCH` | Manual/automatic KDC gates remain on the step after cancellation, transport, or credential-save failure and expose an error. |
+| NNHA-STEP5-001 | `MATCH` | Stop-all request is first and request polling is used. |
+| NNHA-STEP5-002 | `MATCH` | The additional NN chain performs existence, optional component creation, registration, then install, and stops on any non-idempotent failure. |
+| NNHA-STEP5-003 | `MATCH` | The same fail-closed, idempotent chain covers the complete selected JN set. |
+| NNHA-STEP5-004 | `MATCH` | HDFS/Ranger save and HDFS client install are ordered and any save/install failure stops the task. |
+| NNHA-STEP5-005 | `MATCH` | Secure mode reloads exact current tags/configs, overlays Review changes, removes obsolete keys, and submits the merged snapshot. |
+| NNHA-STEP5-006 | `MATCH` | All selected JNs are sent to a STARTED host-component update. |
+| NNHA-STEP5-007 | `MATCH` | SNN maintenance is set to ON without deleting it. |
+| NNHA-STEP6-001 | `MATCH` | The configured HDFS user command is shown and one poll cycle concurrently fetches each selected JN. |
+| NNHA-STEP6-002 | `MATCH` | Malformed/missing formatted JSON is handled without throwing and Next starts disabled. |
+| NNHA-STEP6-003 | `MATCH` | A host-keyed complete-set barrier requires every selected JN, including fourth/fifth responses. |
+| NNHA-STEP7-001 | `MATCH` | ZooKeeper Server hosts are started first. |
+| NNHA-STEP7-002 | `MATCH` | Authoritative component topology conditionally adds the installed Ambari Infra service task. |
+| NNHA-STEP7-003 | `MATCH` | Installed MySQL Server hosts are conditionally started before Ranger/current NN. |
+| NNHA-STEP7-004 | `MATCH` | Installed Ranger Admin hosts are resolved from authoritative topology and started conditionally. |
+| NNHA-STEP7-005 | `MATCH` | Only the installed/original NN is started last. |
+| NNHA-STEP8-001 | `MATCH` | `hdfs zkfc -formatZK` is shown without server validation. |
+| NNHA-STEP8-002 | `MATCH` | `hdfs namenode -bootstrapStandby` is shown for the additional NN. |
+| NNHA-STEP8-003 | `MATCH` | KDC validation is followed by explicit manual-completion confirmation before Step 9. |
+| NNHA-STEP9-001 | `MATCH` | The additional NN is started and polled. |
+| NNHA-STEP9-002 | `MATCH` | ZKFC existence/create/register/install/start is fail closed on both NN hosts. |
+| NNHA-STEP9-003 | `MATCH` | Historical PXF is installed on the additional NN only when the service is present and the component is absent there. |
+| NNHA-STEP9-004 | `MATCH` | Ranger env and every installed YARN/Storm/Kafka/Knox/Atlas/Hive/KMS group are loaded and submitted using JavaScript-safe lookups. |
+| NNHA-STEP9-005 | `MATCH` | HBase and present Ranger HBase sites are saved together. |
+| NNHA-STEP9-006 | `MATCH` | Complete `ams-hbase-site` is submitted whenever AMS is installed. |
+| NNHA-STEP9-007 | `MATCH` | `accumulo-site` is conditionally submitted. |
+| NNHA-STEP9-008 | `MATCH` | `hawq-site` and `hdfs-client` are saved together. |
+| NNHA-STEP9-009 | `MATCH` | SNN host-component is deleted directly. |
+| NNHA-STEP9-010 | `MATCH` | HDFS is stopped, then all services are started without smoke tests. |
+| NNHA-STEP9-011 | `MATCH` | HAWQ acknowledgement precedes an awaited owner/checkpoint clear; clear failure remains visible and blocks navigation. |
+| NNHA-CONFIG-001 | `MATCH` | Both nameservice properties use the entered ID. |
+| NNHA-CONFIG-002 | `MATCH` | `nn1,nn2` and configured failover provider are generated. |
+| NNHA-CONFIG-003 | `MATCH` | Current RPC port and additional default 8020 are generated. |
+| NNHA-CONFIG-004 | `MATCH` | Current HTTP/HTTPS ports and additional defaults are generated. |
+| NNHA-CONFIG-005 | `MATCH` | All selected JNs are joined with `;` at port 8485. |
+| NNHA-CONFIG-006 | `MATCH` | `fs.defaultFS` and comma-separated ZooKeeper quorum are generated. |
+| NNHA-CONFIG-007 | `MATCH` | Fencing, automatic failover, and safemode values come from the descriptor. |
+| NNHA-CONFIG-008 | `MATCH` | Old keys are removed both from Review and the authoritative secure reload/overlay submission. |
+| NNHA-CONFIG-009 | `PARTIAL` | Default/editability match; the Windows current-value branch is absent. |
+| NNHA-CONFIG-010 | `MATCH` | HBase authority replacement matches Classic. |
+| NNHA-CONFIG-011 | `MATCH` | Accumulo volume/replacements are generated. |
+| NNHA-CONFIG-012 | `MATCH` | AMS changes only on current-NN authority match and is always submitted when installed. |
+| NNHA-CONFIG-013 | `MATCH` | HAWQ URL migration and the completion filespace acknowledgement are both present. |
+| NNHA-CONFIG-014 | `MATCH` | Generated `hdfs-client` values are submitted with `hawq-site`. |
+| NNHA-CONFIG-015 | `MATCH` | HDFS/HBase/AMS/Accumulo and all applicable Ranger/HAWQ sites are submitted from the reviewed snapshot. |
+| NNHA-CONFIG-016 | `MATCH` | Ranger's multi-configuration PUT contains one desired-config body per installed service group. |
+
+### JournalNode Entry, Modes, and Steps
+
+| Feature ID | Status | Classic versus current React |
+| --- | --- | --- |
+| JN-ENTRY-001 | `MATCH` | Service action requires the feature flag, HDFS HA model, and exact `(hosts > JNs || JNs > 3)` cardinality. |
+| JN-ENTRY-002 | `MATCH` | Menu and route accept Classic's custom-command, service-check, maintenance, or enable-HA service permissions, plus the host permission for Host Details. |
+| JN-ENTRY-003 | `MATCH` | Active and Standby HA labels are required; work state is not checked. |
+| JN-ENTRY-004 | `MATCH` | Host Details add/delete uses `HOST.ADD_DELETE_COMPONENTS`, routes to this wizard, applies KDC before add, and does not require KDC for delete. |
+| JN-ENTRY-005 | `MATCH` | Host Details disables deletion at three JNs and the wizard independently enforces the same minimum. |
+| JN-MODE-001 | `MATCH` | Add-only begins from the live set, records only added hosts, and retains all seven steps. |
+| JN-MODE-002 | `MATCH` | Delete-only hides Save Namespace and Copy Directories, jumps to Step 4, and renders five numbered steps. |
+| JN-MODE-003 | `MATCH` | Mixed mode can reach all seven steps. |
+| JN-MODE-004 | `MATCH` | No-op, under-minimum, over-maximum, and duplicate final sets keep Next disabled. |
+| JN-STEP1-001 | `MATCH` | Current assignment, add/remove controls, Advisor readiness, uniqueness, minimum/maximum, and real-change validation gate Next. |
+| JN-STEP1-002 | `MATCH` | Maximum is `min(hostCount, max(3, existingCount * 2 - 1))`. |
+| JN-STEP1-003 | `MATCH` | Final topology is stored from service or Host Details entry and serialized before navigation. |
+| JN-STEP2-001 | `MATCH` | Add/delete Review and read-only shared-edits config are shown after load. |
+| JN-STEP2-002 | `MATCH` | Non-Federation shared-edits is generated from the final JN set. |
+| JN-STEP2-003 | `MATCH` | Federation creates exactly one `dfs.namenode.shared.edits.dir.<ns>` descriptor per nameservice. |
+| JN-STEP2-004 | `MATCH` | Fresh descriptors and exact add/delete deltas are persisted; delete-only hides two steps and jumps to Add/Remove. |
+| JN-STEP3-001 | `MATCH` | Single-namespace commands and checkpoint gate exist. |
+| JN-STEP3-002 | `MATCH` | Federation renders safemode/checkpoint commands with `-fs hdfs://<ns>` for every nameservice. |
+| JN-STEP3-003 | `MATCH` | React derives active hosts and falls back to started/first hosts by namespace. |
+| JN-STEP3-004 | `MATCH` | Every response must pass checkpoint metrics; desired-state and parse/transport failures are visible while polling retries. |
+| JN-STEP3-005 | `MATCH` | The aggregate host-component endpoint is used and exact host identity/count rejects missing, duplicate, or extra subsets. |
+| JN-STEP4-001 | `MATCH` | Saved Standby NN is stopped first. |
+| JN-STEP4-002 | `MATCH` | All services are stopped second. |
+| JN-STEP4-003 | `MATCH` | Empty add sets complete idempotently; non-empty sets use the fail-closed component install chain. |
+| JN-STEP4-004 | `MATCH` | All deletes are awaited and explicit 404/NoSuchResource responses are idempotent. |
+| JN-STEP4-005 | `MATCH` | React waits for every DELETE promise before reconfiguration. |
+| JN-STEP4-006 | `MATCH` | Final `hdfs-site` save precedes HDFS client install and any failure stops the sequence. |
+| JN-STEP5-001 | `MATCH` | Single/Federation directory properties are required and deduplicated; missing config exposes Retry. |
+| JN-STEP5-002 | `MATCH` | Next is a manual acknowledgment and performs no validation. |
+| JN-STEP6-001 | `MATCH` | An authoritative post-change component GET supplies the exact final JN hosts before STARTED. |
+| JN-STEP7-001 | `MATCH` | Start-all resumes from persisted status; owner/state clearing is awaited and failures block navigation. |
+
+### Progress, Recovery, Rollback, and Risks
+
+| Feature ID | Status | Classic versus current React |
+| --- | --- | --- |
+| NNHA-PROGRESS-001 | `MATCH` | Operations are serial and each QUEUED/request/status checkpoint must persist before callback execution or advancement. |
+| NNHA-PROGRESS-002 | `MATCH` | Requests poll at two seconds; transient transport/incomplete responses remain visible and schedule controlled retry. |
+| NNHA-PROGRESS-003 | `MATCH` | `FAILED`, `TIMEDOUT`, and `ABORTED` are terminal failures; only `COMPLETED` advances. |
+| NNHA-PROGRESS-004 | `MATCH` | Refresh restores completed/in-progress/failed tasks and Retry reruns only the active failed operation. |
+| NNHA-PROGRESS-005 | `MATCH` | No normal task exposes Skip. |
+| NNHA-PROGRESS-006 | `MATCH` | Request-bearing operations open Background Operations and task logs. |
+| NNHA-PROGRESS-007 | `MATCH` | Manual Kerberos passes directly; automatic KDC cancellation, transport, lookup, or credential-save failure rejects the task with a visible error. |
+| NNHA-PROGRESS-008 | `MATCH` | Create/register/install is fail closed; only explicit already-exists service-component responses continue idempotently. |
+| NNHA-PROGRESS-009 | `MATCH` | Existence/topology GETs precede installs and authoritative reloads precede dependency and final-JN starts. |
+| NNHA-PROGRESS-010 | `MATCH` | The exact batch chain runs in order and no asynchronous existence branch can return before registration/install. |
+| NNHA-PROGRESS-011 | `MATCH` | Credential GET is awaited, then existing credentials use PUT and explicit 404 uses POST; save failure prevents replay. |
+| NNHA-RECOVERY-001 | `MATCH` | Step/task/request state, active step, cluster status, and owner share one normalized serialized `/persist` checkpoint. |
+| NNHA-RECOVERY-002 | `MATCH` | Hydration blocks rendering, restores the named step and operations, resumes request polling, and exposes Retry on load failure. |
+| NNHA-RECOVERY-003 | `MATCH` | Early Cancel clears persisted state and returns to HDFS Summary. |
+| NNHA-RECOVERY-004 | `MATCH` | Steps 5-9 show a manual-recovery warning and retain owner/checkpoint instead of claiming rollback. |
+| NNHA-RECOVERY-005 | `NOT_APPLICABLE` | The Classic automatic rollback target is placeholder-only and is not a parity target. |
+| JN-RECOVERY-001 | `MATCH` | Assignment/config/task/request/step data is serialized and every progress step consumes restored operations. |
+| JN-RECOVERY-002 | `MATCH` | Critical Cancel warns and retains completed-side-effect evidence and owner; early Cancel clears pre-change state. |
+| JN-RECOVERY-003 | `MATCH` | JN status, route step, workflow key, and `wizard-data.userName` restore the owner while other users are blocked. |
+| NNHA-RECOVERY-006 | `MATCH` | Menu and direct route require persisted-data authorization and any failed checkpoint blocks mutation/navigation. |
+| NNHA-ROLLBACK-001 | `NOT_APPLICABLE` | React correctly does not claim parity with the three-step placeholder. |
+| NNHA-ROLLBACK-002 | `NOT_APPLICABLE` | Placeholder checkpoint wiring is not a migration target. |
+| NNHA-ROLLBACK-003 | `NOT_APPLICABLE` | Empty completion page is not a migration target. |
+| NNHA-ROLLBACK-004 | `NOT_APPLICABLE` | Hidden, incorrect Disable navigation is not implemented. |
+| NNHA-ROLLBACK-005 | `NOT_APPLICABLE` | Unwired static reverse controller is not reused. |
+| NNHA-ROLLBACK-006 | `NOT_APPLICABLE` | Invalid rollback route/state is not reused. |
+| NNHA-ROLLBACK-007 | `NOT_APPLICABLE` | Defective rollback interfaces/types are not reused. |
+| NNHA-ROLLBACK-008 | `MATCH` | React exposes no Disable or automatic rollback claim. |
+| JN-RISK-001 | `MATCH` | Checkpoint/formatted JSON and polling errors are visible, non-throwing, and retried under a disabled Next gate. |
+| JN-RISK-002 | `MATCH` | Complete selected-host barriers cover NNHA formatted status and Federation checkpoint response sets. |
+| JN-RISK-003 | `MATCH` | All JN deletion promises are awaited. |
+| JN-RISK-004 | `MATCH` | Final JN topology is reloaded from the server before STARTED. |
+| JN-RISK-005 | `MATCH` | Federation validates exact expected host identity/cardinality. |
+| JN-RISK-006 | `MATCH` | Review clones descriptors and assignment data on every load. |
+| JN-RISK-007 | `MATCH` | Component create, register, KDC, and install failures stop the operation. |
+| JN-RISK-008 | `MATCH` | Service and Host Details entries converge on feature, permissions, HA, cardinality, and persisted owner route guards. |
+| JN-RISK-009 | `MATCH` | Critical exit warns and retains recoverable state instead of silently clearing it. |
+| JN-RISK-010 | `MATCH` | React does not reuse or advertise the defective rollback controller. |
+
+### API Contract
+
+All paths below use the `/api/v1` prefix. Query values containing cluster, host, stack, service, component, tag, request, or task identifiers are URL variables.
+
+| Feature ID | Status | React URL, method, query/payload, and order |
+| --- | --- | --- |
+| HA-API-001 | `MATCH` | `GET /clusters/{cluster}/hosts?fields=host_components/HostRoles/state&minimal_response=true`; base host/component topology loads before assignment. |
+| HA-API-002 | `MATCH` | `GET /clusters/{cluster}/hosts?fields=Hosts/cpu_count,Hosts/disk_info,Hosts/total_mem,Hosts/maintenance_state&minimal_response=true`; assignment host details. |
+| HA-API-003 | `MATCH` | Two `POST {stackVersionUrl}/recommendations` requests with `recommend:host_groups`, hosts, services, blueprint/binding. |
+| HA-API-004 | `MATCH` | `GET /clusters/{cluster}?fields=Clusters/desired_configs`; config hook first. |
+| HA-API-005 | `MATCH` | `GET /clusters/{cluster}/configurations?(type=X&tag=Y)\|...`; exact desired tag combinations follow HA-API-004. |
+| HA-API-006 | `MATCH` | `GET /clusters/{cluster}/hosts/{host}/host_components/NAMENODE`; NNHA and single-namespace JN checkpoint. |
+| HA-API-007 | `MATCH` | Federation uses `GET /clusters/{cluster}/host_components?HostRoles/component_name=NAMENODE&HostRoles/host_name.in(...)&fields=HostRoles/desired_state,metrics/dfs/namenode&minimal_response=true`. |
+| HA-API-008 | `MATCH` | One `GET .../hosts/{host}/host_components/JOURNALNODE?fields=metrics` per selected JN. |
+| HA-API-009 | `MATCH` | `PUT /clusters/{cluster}/services?{service predicate}` with `{RequestInfo:{context,operation_level:{level:"CLUSTER",cluster_name}},Body:{ServiceInfo:{state}}}`; every request is polled before the next mutation. |
+| HA-API-010 | `MATCH` | `PUT /clusters/{cluster}/host_components` with `RequestInfo.query=HostRoles/component_name=...&HostRoles/host_name.in(...)`, context/operation level, and `Body.HostRoles.state`. |
+| HA-API-011 | `MATCH` | `PUT /clusters/{cluster}/hosts/{host}/host_components/{component}?{params}` carries the single-component state body; normal flows use HA-API-010. |
+| HA-API-012 | `MATCH` | `PUT /clusters/{cluster}/hosts/{host}/host_components/SECONDARY_NAMENODE` with `{RequestInfo:{context},Body:{HostRoles:{maintenance_state:"ON"}}}`. |
+| HA-API-013 | `MATCH` | `DELETE /clusters/{cluster}/hosts/{host}/host_components/{component}` deletes SNN or JN; Manage JN treats only explicit 404/NoSuchResource as idempotent. |
+| HA-API-014 | `MATCH` | `PUT /clusters/{cluster}` with `{Clusters:{desired_config:[{type,properties,properties_attributes?,service_config_version_note}]}}`. |
+| HA-API-015 | `MATCH` | Ranger sends one multi-body `PUT /clusters/{cluster}` with an exact desired-config body for every applicable service group. |
+| HA-API-016 | `MATCH` | `GET /clusters/{cluster}/host_components?HostRoles/component_name={component}&HostRoles/host_name.in({hosts})&fields=HostRoles/host_name&minimal_response=true` precedes component registration. |
+| HA-API-017 | `MATCH` | Missing service-component uses `POST /clusters/{cluster}/services?ServiceInfo/service_name={service}` with `components[].ServiceComponentInfo.component_name`; only explicit already-exists is idempotent. |
+| HA-API-018 | `MATCH` | `POST /clusters/{cluster}/hosts` carries `RequestInfo.query=Hosts/host_name=...\|...` and `Body.host_components[].HostRoles.component_name`; failure prevents Install. |
+| HA-API-019 | `MATCH` | `GET /clusters/{cluster}/components/?fields=ServiceComponentInfo/{service_name,category,installed_count,started_count,init_count,install_failed_count,unknown_count,total_count,display_name},host_components/HostRoles/host_name&minimal_response=true` refreshes topology. |
+| HA-API-020 | `MATCH` | `GET /clusters/{cluster}/components/?fields=ServiceComponentInfo/{service_name,component_name,installed_count},host_components/HostRoles/host_name` precedes Step 7 tasks and final JN start. |
+| HA-API-021 | `MATCH` | `GET /requests/{id}?fields=*,tasks/...` polls at two seconds; transient transport/incomplete responses retry without advancing. |
+| HA-API-022 | `MATCH` | `GET /clusters/{cluster}/requests/{requestId}?fields=tasks/...&tasks/Tasks/stage_id={stageId}` supports request/stage detail polling. |
+| HA-API-023 | `MATCH` | `GET /clusters/{cluster}/requests/{requestId}/tasks/{taskId}` retrieves stdout/stderr/output/error details. |
+| HA-API-024 | `MATCH` | `GET /clusters/{cluster}/configurations/service_config_versions?service_name=KERBEROS&is_current=true` reads `kerberos-env/kdc_type`. |
+| HA-API-025 | `MATCH` | `GET /clusters/{cluster}/services/KERBEROS?fields=Services/attributes/kdc_validation_result,Services/attributes/kdc_validation_failure_details` is awaited; Manual Kerberos skips it. |
+| HA-API-026 | `MATCH` | `GET`, then `PUT` or 404-only `POST /clusters/{cluster}/credentials/kdc.admin.credential`; payload is `{Credential:{principal,key,type}}`, and failed save prevents replay. |
+| HA-API-027 | `MATCH` | `GET /persist/HIGH_AVAILIBILITY_NAMENODE` or `/persist/MANAGE_JOURNALNODES`; 404 starts fresh, other load failures block rendering with Retry. |
+| HA-API-028 | `MATCH` | `POST /persist` sends JSON-string values for the workflow key, `CLUSTER_STATE`, and `wizard-data:{userName}`; mutation/navigation waits for success. |
+| HA-API-029 | `NOT_APPLICABLE` | Registered rollback host loading is outside the normal React workflows. |
+| HA-API-030 | `NOT_APPLICABLE` | Static unwired rollback interfaces are not migration targets. |
+| HA-API-031 | `MATCH` | `GET /clusters/{cluster}/configurations/service_config_versions?service_name.in(HDFS)&is_current=true&fields=*` supplies `hadoop-env/hdfs_user` to both workflows. |
+
+Normal NNHA request ordering must be:
+
+1. Host/stack topology, Advisor recommendations, desired tags, and exact config versions.
+2. Current NN checkpoint polling, then KDC session validation.
+3. Stop-all request and terminal polling.
+4. Additional NN install chain, then all selected JN install chains.
+5. HDFS/Ranger config submission and HDFS client install, then start all selected JNs and disable SNN.
+6. Complete-set JN formatted polling.
+7. ZooKeeper/conditional dependencies/current NN starts in strict order.
+8. Manual metadata confirmation and KDC validation.
+9. Additional NN, ZKFC install/start, conditional dependency config saves, SNN delete, HDFS stop, and start-all in strict order.
+
+Normal Manage JN request ordering must be:
+
+1. Host/topology, Advisor, desired tags, and exact HDFS configuration versions.
+2. For non-delete-only, exact namespace checkpoint polling.
+3. Stop Standby NN, stop all services, install all added JNs, delete all removed JNs, save HDFS, and install HDFS clients.
+4. For non-delete-only, manual directory-copy acknowledgment.
+5. Reload final JournalNode topology, start the exact final set, then start all services.
+
+No mutation may begin until the previous mutation is terminal and its workflow checkpoint is durably persisted. Retry repeats only the failed command or failed per-host item. Cancel stops client polling; it does not cancel an Ambari request unless an explicit request-abort API is added.
+
+## Permission, Flag, and Stack Matrix
+
+| Surface | Required React policy | React result / remaining validation |
+| --- | --- | --- |
+| NNHA menu and direct route | `SERVICE.ENABLE_HA` and `CLUSTER.MANAGE_USER_PERSISTED_DATA`; HDFS `HA_MODE`; HA disabled | Enforced in the menu/route/model; exact supported stack versions remain `NEEDS_RUNTIME_VALIDATION`. |
+| NNHA business gate | At least 3 registered hosts, 3 ZooKeeper Servers, a started NN, no HDFS master explicit/implied maintenance | Enforced before rendering Step 1 and on direct routes. |
+| Manage JN service action | Classic visibility under `RUN_CUSTOM_COMMAND`, `RUN_SERVICE_CHECK`, `TOGGLE_MAINTENANCE`, or `ENABLE_HA`, plus persisted-data capability | Menu and direct route use the combined service alternatives, persistence permission, feature flag, HA, and cardinality. |
+| Manage JN Host Details | `HOST.ADD_DELETE_COMPONENTS`, HA/topology/cardinality gate, KDC check for add | Existing add/delete navigation is retained; add passes through KDC and delete is disabled at three JNs. |
+| Manage JN route | Persisted-data plus accepted service/host mutation permission; HA enabled; Active/Standby; supported stack; add/delete cardinality | Enforced by feature/authorization/owner guards followed by workflow validation. |
+| Service conditions | Ranger, HBase, AMS, Accumulo, HAWQ/PXF, Infra, MySQL branches only when installed/component-count conditions match | Authoritative topology/config tags build only applicable tasks; historical combinations remain runtime matrix cases. |
+| Kerberos | Non-secure, automatic KDC, and Manual Kerberos; cancellation/error must terminate the current transition without losing owner state | Static branches are fail closed and tested; MIT/AD/IPA credential-store behavior remains runtime validation. |
+| Auto rollback | Do not advertise automatic rollback or Disable NNHA parity | React correctly exposes neither. |
+
+## Test Feature IDs
+
+| Feature ID | Status | React evidence or required coverage |
+| --- | --- | --- |
+| HA-TEST-001 | `PARTIAL` | Focused NNHA Step 7 topology/Retry and Step 9 completion/HAWQ tests exist; full rendered Steps 1-9 matrix remains runtime coverage. |
+| HA-TEST-002 | `PARTIAL` | Manage JN provider hydration/delete-only and Step 7 completion failure are covered; full rendered Steps 1-7 matrix remains runtime coverage. |
+| HA-TEST-003 | `MATCH` | Progress tests prove pre-execution persistence, request-ID persistence Retry, terminal error retention, and Utility terminal statuses. |
+| HA-TEST-004 | `PARTIAL` | Pure config helpers cover Federation generation, secure overlay, edited Review value, desired-tag errors, and Ranger groups; historical full initializer combinations remain runtime. |
+| HA-TEST-005 | `PARTIAL` | Shared authorization/route guards have tests and Module 09 entry code was statically audited; the complete browser permission matrix remains runtime. |
+| HA-TEST-006 | `PARTIAL` | Both providers test hydration, delete-only recovery, and persisted owner; browser/server restart and second-user behavior remain runtime. |
+| HA-TEST-007 | `NOT_APPLICABLE` | Placeholder/unwired Classic rollback is not implemented; absence must remain explicit. |
+| HA-TEST-008 | `MATCH` | Utility tests cover missing fourth response and a complete four-JN set independently of response order. |
+| HA-TEST-009 | `PARTIAL` | Idempotent delete and topology-load Retry are covered; multi-DELETE server timing and final JN start require runtime capture. |
+| HA-TEST-010 | `MATCH` | Federation utilities reject missing/duplicate hosts and accept a complete out-of-order set. |
+| HA-TEST-011 | `PARTIAL` | Automatic KDC transport/cancel/save ordering and Manual bypass are tested; actual MIT/AD/IPA stores remain runtime. |
+| HA-TEST-012 | `MISSING` | Full real-cluster success/recovery matrix remains required. |
+
+## Executable Acceptance Criteria
+
+Criteria 1-15 are implemented in the reviewed React code and focused tests. Items that depend on real Ambari agents, stack metadata, credential stores, or server/browser restart must additionally pass the runtime matrix.
+
+1. Direct NNHA entry is rejected before opening Step 1 for missing authorization, missing persisted-data capability, unsupported stack/service, HA already enabled, fewer than three hosts/ZooKeeper Servers, missing or stopped NN, or any HDFS master maintenance state.
+2. NNHA assignment cannot advance until exactly one additional NN and at least three unique valid JNs are selected; 4/5 JNs remain in the generated shared-edits URI and formatted-status barrier.
+3. Review loads immutable fresh descriptors on every visit, submits exact current config snapshots, exposes load/generation errors with Retry, and never duplicates or permanently renames descriptor entries.
+4. Checkpoint JSON parse and GET failures display a recoverable error and continue controlled polling after Retry; Next never enables from a missing, malformed, partial, or wrong-host response.
+5. Component installation stops on service-component create, host registration, KDC, or Install failure. An explicit ResourceAlreadyExists response may continue idempotently; other errors retain the failed task and original server message.
+6. Progress executes one operation at a time, treats `FAILED`, `TIMEDOUT`, and `ABORTED` as failure, persists request IDs/status before advancing, restores polling after refresh, and retries only the failed operation.
+7. NNHA Step 6 waits for every selected JN, associates each response with its host, and enables Next only when every host reports the current nameservice formatted.
+8. NNHA Step 8 requires explicit confirmation after a successful KDC gate. Steps 5-9 do not offer unsafe Back navigation, and critical Cancel preserves completed-side-effect evidence with a manual recovery warning.
+9. NNHA Step 9 executes every applicable PXF/Ranger/HBase/AMS/Accumulo/HAWQ branch, includes `hdfs-client` for HAWQ, then deletes SNN, stops HDFS, and starts all services in order.
+10. Manage JN begins with the live JN set, enforces minimum/maximum/no-op rules, supports add-only/delete-only/mixed, and displays five renumbered steps for delete-only.
+11. Federation Review writes exactly one shared-edits property per nameservice. Checkpoint renders one command set per nameservice and requires a one-to-one expected namespace/host response set.
+12. JN deletion awaits all hosts and reports failures by host. Retry targets only failed deletions. Reconfigure cannot start while any deletion is unresolved.
+13. Before JN Step 6, React reloads server topology and starts exactly the final JN set, excluding deleted hosts and including every newly registered host.
+14. Workflow state hydration, step transitions, task checkpoints, completion, and Cancel are serialized. A failed `/persist` request blocks further mutation/navigation and displays a retryable error.
+15. Focused tests cover validation helpers, complete-set aggregation, Federation cardinality, install prerequisite failure, deletion aggregation, topology refresh, task recovery, and persistence ordering.
+
+## Runtime Acceptance Matrix
+
+| Scenario | Topology/mode | Fault or transition points | Required evidence | Status |
+| --- | --- | --- | --- | --- |
+| NNHA non-secure | 3 hosts, 3 JNs, HDFS/ZK only | Refresh before/after every Step 5/7/9 mutation | Browser route/owner snapshot, ordered HTTP capture, Ambari request/task terminal states, final HDFS config version, Active/Standby/ZKFC topology. | `NEEDS_RUNTIME_VALIDATION` |
+| NNHA 5 JNs | 5+ hosts, out-of-order formatted responses | Delay the fourth/fifth JN and return malformed JSON once | Next remains disabled until all five valid responses; visible recovery; shared-edits includes all five. | `NEEDS_RUNTIME_VALIDATION` |
+| NNHA automatic Kerberos | MIT, AD, and IPA where supported | Expired KDC session, popup cancel, bad credential save, retry | No mutation after cancel/failure; persisted owner survives; successful replay executes once. | `NEEDS_RUNTIME_VALIDATION` |
+| NNHA Manual Kerberos | `kdc_type=none` | Refresh at install and metadata gates | No KDC admin credential required; component identities/keytabs install; progress resumes. | `NEEDS_RUNTIME_VALIDATION` |
+| NNHA dependencies | Ranger, HBase, AMS, Accumulo, historical HAWQ/PXF combinations | Missing optional site/property and config-save failure | Only applicable sites submitted, complete snapshots retained, failure stops sequence, HAWQ warning shown. | `NEEDS_RUNTIME_VALIDATION` |
+| NNHA Windows branch | A stack exposing the Windows JournalNode directory default | Review and secure reload | `dfs.journalnode.edits.dir` uses the stack/runtime-supported default and survives submission. | `NEEDS_RUNTIME_VALIDATION` |
+| NNHA critical exit | Steps 5, 6, 7, 8, and 9 | Close modal after each completed command | Completed-side-effect list retained; no false rollback claim; manual recovery is actionable. | `NEEDS_RUNTIME_VALIDATION` |
+| JN add-only | 3 to 4/5 JNs | Refresh after registration and before start | Seven steps; exact add set; final reload starts all final JNs. | `NEEDS_RUNTIME_VALIDATION` |
+| JN delete-only | 5 to 3/4 JNs | One DELETE delayed and one NoSuchResource | Five renumbered steps; no checkpoint/copy; all deletions terminal before config save. | `NEEDS_RUNTIME_VALIDATION` |
+| JN mixed | Replace one or more JNs | One delete fails, retry after other deletes succeed | Retry only failed host; no duplicate install/delete; final config/topology exact. | `NEEDS_RUNTIME_VALIDATION` |
+| JN no-op/cardinality | 3, 4, and 5 existing JNs with varying host count | Return to Step 1 after Review | No-op disabled; minimum three and `existing*2-1` maximum enforced; snapshots invalidated correctly. | `NEEDS_RUNTIME_VALIDATION` |
+| JN Federation | 2+ nameservices | Missing, duplicate, wrong-host, and non-STARTED checkpoint items | Exact namespace properties and commands; no partial-response pass. | `NEEDS_RUNTIME_VALIDATION` |
+| Persistence ownership | Both workflows | Server restart, browser refresh, second user login, permission revoked | Authorized owner resumes; unauthorized user cannot mutate/overwrite; persist failure blocks progression. | `NEEDS_RUNTIME_VALIDATION` |
+| Request failures | Both workflows | `FAILED`, `TIMEDOUT`, `ABORTED`, poll 500, and transient network loss | Visible terminal status, controlled polling recovery, correct Retry target, no next-command execution. | `NEEDS_RUNTIME_VALIDATION` |
+
+## Five-Pass Audit Record
+
+| Pass | Surface | Result |
+| --- | --- | --- |
+| 1 | Routes, pages, buttons, and wizard navigation | Confirmed service and Host Details entries, corrected the original React audit's false Host Details gap, unified route permissions/business gates, removed critical Back navigation, and implemented delete-only hide/renumber/jump. The upgrade custom-check shortcut remains the only missing entry. |
+| 2 | Controller/service/model state and recovery | Compared Classic local DB/task/request checkpoints with both React providers and `OperationsProgress`; serialized writes, normalized wire encoding, blocked rendering during hydration, restored every progress page, and persisted `wizard-data.userName`. |
+| 3 | API contracts and request order | Traced every operation through `adminApi`, `HostsApi`, `ServiceApi`, `ConfigsApi`, `RequestApi`, credentials, and `/persist`; corrected Federation aggregation, fail-closed install/config behavior, final topology reload, Ranger groups, and credential GET/PUT/POST order. |
+| 4 | Modes, permissions, feature flags, stack/service branches | Checked add/delete/mixed/no-op, HA state, service/host/persistence permissions, `manageJournalNode`, Kerberos, Federation, and every dependency service; implemented static branches and retained stack/history combinations in the runtime matrix. |
+| 5 | Error, Retry, refresh, Back, Cancel, and interrupted recovery | Fault-read polling, task checkpoints, install/deletion/config/KDC/persist failures, completion, and critical exit; failures now remain visible and recoverable without advancing or discarding owner state. |
+
+No executable Classic evidence contradicted the current Ember baseline, so no baseline or generated evidence file was changed. The initial React gap document incorrectly reported the existing Host Details JournalNode routes and three-JN deletion gate as absent; this document now records their source-backed behavior.
+
+## Verification Evidence
+
+Focused Vitest coverage passes 47 tests in 13 files for request checkpoint ordering, terminal failure/Retry, fail-closed component registration, idempotent deletion, NNHA/JN assignment and Federation helpers, complete 4/5-JN barriers, secure config overlay, Review edit submission, Ranger groups, topology Retry, completion persistence, workflow owner/hydration, KDC cancellation, credential persistence ordering, and Manual Kerberos bypass. The full React suite passes 229 tests in 65 files; [...]
+
+Shared-file changes are limited to Module 09 contracts: `Utility` terminal statuses; `taskUtils` install/delete ordering; `OperationsProgress` checkpoints/poll recovery; `AssignMastersAddable` assignment bounds; Step Wizard hidden-step/navigation/cancel support; `useStepWizard`; `useConfigsTags`; route/service-action guards; KDC popup/session handling; and credential persistence ordering. No Module 05, Module 06, or other module gap document or implementation was changed.
diff --git a/docs/frontend-refactor/react-current/issues/09-namenode-journalnode-ha.md b/docs/frontend-refactor/react-current/issues/09-namenode-journalnode-ha.md
new file mode 100644
index 0000000000..f84a925490
--- /dev/null
+++ b/docs/frontend-refactor/react-current/issues/09-namenode-journalnode-ha.md
@@ -0,0 +1,48 @@
+<!---
+ 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.
+-->
+
+## Problem
+
+The React NameNode HA and JournalNode management wizards expose the expected routes and step pages, but they do not yet preserve the complete Classic Ambari workflow. Several gaps can allow an irreversible phase to start with invalid state, incorrectly accept incomplete JournalNode formatting, lose progress during refresh, or continue after a prerequisite request fails.
+
+The detailed Feature ID audit is recorded in `docs/frontend-refactor/react-current/09-namenode-journalnode-ha-gap.md`.
+
+## Proposed Change
+
+Complete Module 09 parity as one change:
+
+- enforce route, persisted-data, topology, HA-state, stack, and service preconditions;
+- preserve the nine-step NameNode HA and seven-step/five-step JournalNode state machines, including safe Back, Cancel, and recovery boundaries;
+- wait for the complete selected JournalNode and Federation namespace response sets;
+- make component installation, configuration, deletion, and progress polling fail closed with focused Retry behavior;
+- implement add-only, delete-only, mixed, and no-op JournalNode handling, including delete-only step skipping;
+- reload final JournalNode topology before starting components;
+- complete dependency-service configuration branches used by the NNHA finalization sequence;
+- serialize workflow persistence with navigation and retain recoverable task/request checkpoints; and
+- add focused React tests for validation, aggregation, API ordering, failures, retry, and refresh recovery.
+
+Metrics product functionality is outside this issue. Operational checkpoint and JournalNode formatted fields below `metrics/dfs/...` remain in scope. Disable NameNode HA and automatic rollback are not claimed because the Classic implementations are placeholder-only or unreachable.
+
+## Acceptance Criteria
+
+1. NameNode HA cannot start or resume without the required authorization, persisted-data capability, supported topology, and safe component state.
+2. Every automatic operation is strictly ordered, durably checkpointed, and stopped on a failed prerequisite or terminal Ambari request.
+3. NNHA formatted-status validation waits for every selected JournalNode, including four- and five-node topologies.
+4. JournalNode management supports add-only, delete-only, mixed, and no-op validation, with five visible steps for delete-only.
+5. Federation configuration and checkpoint validation require an exact response for every expected nameservice/host pair.
+6. Refresh resumes the active request or failed operation without replaying completed mutations.
+7. Focused tests, the complete React test suite, TypeScript, production build, baseline validation, and diff checks pass, with unrelated pre-existing lint failures documented separately.
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@ambari.apache.org
For additional commands, e-mail: commits-help@ambari.apache.org