Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RS-47: manage replication in web console #62

Merged
merged 15 commits into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*

.vscode
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added:

- RS-47: New view in the console to manage replications, [PR-62](https://github.com/reductstore/web-console/pull/62)

## [1.4.1] - 2024-20-01

### Fixed:
Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"antd": "^4.24.14",
"async-wait-until": "^2.0.12",
"babel-jest": "^27.5.1",
"buffer": "^6.0.3",
"enzyme": "^3.11.0",
"humanize-duration": "^3.30.0",
"jest": "^27.5.1",
Expand All @@ -28,12 +29,11 @@
"react-dom": "^17.0.2",
"react-router-dom": "^5.3.4",
"react-scripts": "5.0.1",
"reduct-js": "^1.7.1",
"reduct-js": "1.8",
"stream-browserify": "^3.0.0",
"ts-jest": "^27.1.5",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4",
"buffer": "^6.0.3",
"stream-browserify": "^3.0.0"
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "REACT_APP_VERSION=$npm_package_version react-app-rewired start",
Expand All @@ -42,6 +42,7 @@
"eject": "react-app-rewired eject",
"lint": "eslint . --ext .ts"
},
"proxy": "https://play.reduct.store",
"eslintConfig": {
"extends": [
"react-app",
Expand All @@ -60,4 +61,4 @@
"last 1 safari version"
]
}
}
}
Binary file added public/favicon.ico
Binary file not shown.
Binary file removed public/favicon.png
Binary file not shown.
4 changes: 2 additions & 2 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.png" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.png" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.ico" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
Expand Down
44 changes: 23 additions & 21 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import "antd/dist/antd.variable.min.css";
import "./App.css";
import {IBackendAPI} from "./BackendAPI";
import {Routes} from "./Components/Routes";
import {BorderOuterOutlined, DatabaseOutlined, LockOutlined, LogoutOutlined} from "@ant-design/icons";
import {BorderOuterOutlined, DatabaseOutlined, LockOutlined, LogoutOutlined, ShareAltOutlined} from "@ant-design/icons";
import {TokenPermissions} from "reduct-js";

ConfigProvider.config({
Expand Down Expand Up @@ -60,33 +60,35 @@ export default class App extends React.Component<Props, State> {
<Layout.Sider className="Sider">
<Menu className="MenuItem" selectable={false} triggerSubMenuAction="click">
<a href="https://www.reduct.store" title="https://www.reduct.store">
<Image src={logo} preview={false}/>
<Image src={logo} preview={false} />
</a>

<Divider/>
{permissions ? <>
<Menu.Item icon={<BorderOuterOutlined/>} onClick={() => history.push("/dashboard")}>
<Divider />
{permissions &&
<>
<Menu.Item icon={<BorderOuterOutlined />} onClick={() => history.push("/dashboard")}>
Dashboard
</Menu.Item>
<Menu.Item id="Buckets" icon={<DatabaseOutlined/>}
onClick={() => history.push("/buckets")}>
<Menu.Item id="Buckets" icon={<DatabaseOutlined />}
onClick={() => history.push("/buckets")}>
Buckets
</Menu.Item>

{permissions.fullAccess ?
<Menu.Item id="Security" icon={<LockOutlined/>}
onClick={() => history.push("/tokens")}>
Security
</Menu.Item>
: null}
<Divider style={{borderColor: "white"}}/>

<Menu.Item onClick={onLogout} icon={<LogoutOutlined/>}>
{permissions.fullAccess &&
<>
<Menu.Item id="Replications" icon={<ShareAltOutlined />}
onClick={() => history.push("/replications")}>
Replications
</Menu.Item>
<Menu.Item id="Security" icon={<LockOutlined />}
onClick={() => history.push("/tokens")}>
Security
</Menu.Item>
</>
}
<Divider style={{borderColor: "white"}} />
<Menu.Item onClick={onLogout} icon={<LogoutOutlined />}>
Logout
</Menu.Item>

</>
: <div/>
}
</Menu>
<div className="Meta">
Expand All @@ -99,7 +101,7 @@ export default class App extends React.Component<Props, State> {
<Layout>
<Layout.Content>
<Routes {...this.props} permissions={this.state.permissions}
onLogin={onLogin}/>
onLogin={onLogin} />
</Layout.Content>
</Layout>
</Layout>
Expand Down
32 changes: 16 additions & 16 deletions src/Components/RemoveConfirmationByName.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,20 @@ export default function RemoveConfirmationByName(props: Readonly<Props>) {
};

return (<Modal open={confirmRemove} onOk={() => {
props.onRemoved();
setConfirmRemove(false);
}} onCancel={props.onCanceled} closable={false}
title={`Remove ${props.resourceType} "${props.name}"?`}
okText="Remove"
confirmLoading={!confirmName}
okType="danger">
<p>
For confirmation type <b>{props.name}</b>
</p>
<Form.Item name="confirm">
<Input onChange={(e) => checkName(e.target.value)}></Input>
</Form.Item>
</Modal>
)
;
props.onRemoved();
setConfirmRemove(false);
}} onCancel={props.onCanceled} closable={false}
title={`Remove ${props.resourceType} "${props.name}"?`}
okText="Remove"
confirmLoading={!confirmName}
okType="danger"
data-testid="delete-modal">
<p>
For confirmation type <b>{props.name}</b>
</p>
<Form.Item name="confirm">
<Input onChange={(e) => checkName(e.target.value)} data-testid="confirm-input" ></Input>
</Form.Item>
</Modal>
);
}
168 changes: 168 additions & 0 deletions src/Components/Replication/CreatOrUpdate.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import React from "react";
import {ReactWrapper, mount} from "enzyme";
import {MemoryRouter} from "react-router-dom";

import {Client, ReplicationInfo, ReplicationSettings} from "reduct-js";
import {mockJSDOM, waitUntilFind} from "../../Helpers/TestHelpers";
import CreateOrUpdate from "./CreateOrUpdate";
import {Diagnostics} from "reduct-js/lib/cjs/messages/Diagnostics";

describe("Replication::CreateOrUpdate", () => {
const client = new Client("dummyURL");
let wrapper: ReactWrapper;

beforeEach(() => {
jest.clearAllMocks();
mockJSDOM();

const mockReplicationInfo = ReplicationInfo.parse(
{
name: "TestReplication",
is_active: true,
is_provisioned: true,
pending_records: BigInt(100),
}
);

const mockReplicationSettings = ReplicationSettings.parse(
{
src_bucket: "Bucket1",
dst_bucket: "destinationBucket",
dst_host: "destinationHost",
dst_token: "destinationToken",
entries: ["entry1", "entry2"],
include: {"label1": "value1"},
exclude: {"label2": "value2"},
}
);

const mockDiagnostics = Diagnostics.parse(
{
hourly: {
ok: BigInt(1000),
errored: BigInt(5),
errors: {
0: {
count: 5,
last_message: "Error connecting to source bucket"
},
},
},
}
);

client.getReplication = jest.fn().mockResolvedValue({
info: mockReplicationInfo,
settings: mockReplicationSettings,
diagnostics: mockDiagnostics,
});

client.updateReplication = jest.fn().mockResolvedValue(undefined);

client.createReplication = jest.fn().mockResolvedValue(undefined);

client.getBucket = jest.fn().mockResolvedValue({
getEntryList: jest.fn().mockResolvedValue([
{name: "entry1"},
{name: "entry2"},
]),
});

wrapper = mount(
<MemoryRouter>
<CreateOrUpdate
client={client}
onCreated={() => console.log("")}
sourceBuckets={["Bucket1", "Bucket2"]}
replicationName={"TestReplication"}
readOnly={false}
/>
</MemoryRouter>
);
});

afterEach(() => {
if (wrapper && wrapper.length) {
wrapper.unmount();
}
});

it("renders without crashing", () => {
expect(wrapper.find("form").exists()).toBeTruthy();
});

it("shows a form with all fields", () => {
expect(wrapper.find({name: "name"}).exists()).toBeTruthy();
expect(wrapper.find({name: "srcBucket"}).exists()).toBeTruthy();
expect(wrapper.find({name: "dstBucket"}).exists()).toBeTruthy();
expect(wrapper.find({name: "dstHost"}).exists()).toBeTruthy();
expect(wrapper.find({name: "dstToken"}).exists()).toBeTruthy();
expect(wrapper.find({name: "entries"}).exists()).toBeTruthy();
expect(wrapper.find({name: "recordSettings"}).exists()).toBeTruthy();
});

it("shows the replication name if it is provided", () => {
expect(wrapper.find({name: "name"}).find("input").prop("value")).toEqual("TestReplication");
});

it("shows the selected source bucket if it is provided", async () => {
await waitUntilFind(wrapper, "Select[name='srcBucket']");
const selectedOptionText = wrapper.find({name: "srcBucket"}).find(".ant-select-selection-item").text();
expect(selectedOptionText).toEqual("Bucket1");
});

it("shows the destination bucket if it is provided", async () => {
await waitUntilFind(wrapper, {name: "dstBucket"});
expect(wrapper.find({name: "dstBucket"}).find("input").prop("value")).toEqual("destinationBucket");
});

it("shows the destination host if it is provided", async () => {
await waitUntilFind(wrapper, {name: "dstHost"});
expect(wrapper.find({name: "dstHost"}).find("input").prop("value")).toEqual("destinationHost");
});

it("shows the selected entries if they are provided", async () => {
await waitUntilFind(wrapper, "Select[name='entries']");
const selectedOptionText = wrapper.find({name: "entries"}).find(".ant-select-selection-item");
expect(selectedOptionText.at(0).text()).toEqual("entry1");
expect(selectedOptionText.at(1).text()).toEqual("entry2");
});

it("disables record settings inputs and radios in read-only mode", () => {
wrapper = mount(
<MemoryRouter>
<CreateOrUpdate
client={client}
onCreated={() => console.log("")}
sourceBuckets={["Bucket1", "Bucket2"]}
replicationName={"TestReplication"}
readOnly={true}
/>
</MemoryRouter>
);

// Find all Radio.Group and Input components within the record settings Form.List
const recordSettingsRadios = wrapper.find("Form.List").find("Radio.Group");
const recordSettingsInputs = wrapper.find("Form.List").find("Input");

// Check that each Radio.Group is disabled
recordSettingsRadios.forEach(radioGroup => {
expect(radioGroup.prop("disabled")).toBe(true);
});

// Check that each Input (for both key and value within each record setting) is disabled
recordSettingsInputs.forEach(input => {
expect(input.prop("disabled")).toBe(true);
});

// Check that the delete buttons for each record setting are disabled
const deleteRuleButtons = wrapper.find("Form.List").find("Button[type='primary']").find("DeleteOutlined");
deleteRuleButtons.forEach(deleteButton => {
expect(deleteButton.prop("disabled")).toBe(true);
});

// Check that the Add Rule button is disabled
const button = wrapper.find("Button").filterWhere(node => node.text() === "Update Replication");
expect(button.prop("disabled")).toBe(true);
});
});
Loading
Loading