Skip to content

Commit

Permalink
RS-47: manage replication in web console (#62)
Browse files Browse the repository at this point in the history
* proxy unknown routes in dev to local database

* replication page and settings

* cleanup unused code and comments

* update tags for provisioned status

* test replication card

* test replication view

* test replication details

* test create / update form

* add unreleased changelog info

* replication icon and permission

* remove unused function

* Update favicon

* update tag and column order for consistency with token and bucket lists

* show replication errors in a table

* fix error code and count in table
  • Loading branch information
AnthonyCvn committed Mar 1, 2024
1 parent afd77ae commit e87dfe3
Show file tree
Hide file tree
Showing 19 changed files with 1,320 additions and 61 deletions.
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

0 comments on commit e87dfe3

Please sign in to comment.