Skip to content

Commit

Permalink
RS-180: Web console shows license information (#64)
Browse files Browse the repository at this point in the history
* license component and alert with sample data

* refactor usage statistics in seperated component

* add tabs to seperate usage and license

* update tablist default value

* update helper function to convert bigInt

* remove sample and show real license details

* show license expiryDate in local format

* test license display

* test usage statistics and big int number

* update reduct-js to 1.9.2

* update changelog

* license alert with over-time or over-quota

* update license details

* test license information

* add alert icon in tab when license expires
  • Loading branch information
AnthonyCvn authored Mar 27, 2024
1 parent 86f4f7e commit 2ede59f
Show file tree
Hide file tree
Showing 19 changed files with 6,368 additions and 17,427 deletions.
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-180: Show license information in the web console, [PR-64](https://github.com/reductstore/web-console/pull/64)

## [1.5.0] - 2024-03-01

### Added:
Expand Down
23,062 changes: 5,734 additions & 17,328 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"react-dom": "^17.0.2",
"react-router-dom": "^5.3.4",
"react-scripts": "5.0.1",
"reduct-js": "1.8",
"reduct-js": "^1.9.2",
"stream-browserify": "^3.0.0",
"ts-jest": "^27.1.5",
"typescript": "^4.9.5",
Expand Down Expand Up @@ -61,4 +61,4 @@
"last 1 safari version"
]
}
}
}
81 changes: 39 additions & 42 deletions src/Components/Bucket/BucketCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {DeleteOutlined, SettingOutlined} from "@ant-design/icons";
import "./BucketCard.css";
import CreateOrUpdate from "./CreateOrUpdate";
import RemoveConfirmationByName from "../RemoveConfirmationByName";
import {bigintToNumber} from "../../Helpers/NumberUtils";

interface Props {
bucketInfo: BucketInfo;
Expand All @@ -21,7 +22,7 @@ interface Props {
permissions?: TokenPermissions
}

export const getHistory = (interval: { latestRecord: bigint, oldestRecord: bigint }) => {
export const getHistory = (interval: {latestRecord: bigint, oldestRecord: bigint}) => {
return humanizeDuration(
Number((interval.latestRecord - interval.oldestRecord) / 1000n),
{largest: 1, round: true});
Expand All @@ -32,10 +33,6 @@ export default function BucketCard(props: Readonly<Props>) {
const [changeSettings, setChangeSettings] = useState(false);
const {client, bucketInfo, index} = props;

const n = (big: BigInt) => {
return Number(big.valueOf());
};

const onRemoved = async () => {
const bucket: Bucket = await client.getBucket(bucketInfo.name);
await bucket.remove();
Expand All @@ -46,52 +43,52 @@ export default function BucketCard(props: Readonly<Props>) {
const readOnly = !props.permissions?.fullAccess || bucketInfo.isProvisioned;
if (props.showPanel) {
actions.push(<SettingOutlined title="Settings"
key="setting"
onClick={() => setChangeSettings(true)}/>);
key="setting"
onClick={() => setChangeSettings(true)} />);

if (!readOnly) {
actions.push(<DeleteOutlined title="Remove"
key="delete"
style={{color: "red"}}
onClick={() => setConfirmRemove(true)}/>);
key="delete"
style={{color: "red"}}
onClick={() => setConfirmRemove(true)} />);
}
}

return (<Card className="BucketCard" key={index} id={bucketInfo.name} title={bucketInfo.name}
extra={
bucketInfo.isProvisioned ?
<Tag color="processing">Provisioned</Tag>
: <></>
}
hoverable={props.showPanel != true}
onClick={() => props.onShow(bucketInfo.name)}
actions={actions}>
<Card.Meta>
extra={
bucketInfo.isProvisioned ?
<Tag color="processing">Provisioned</Tag>
: <></>
}
hoverable={props.showPanel != true}
onClick={() => props.onShow(bucketInfo.name)}
actions={actions}>
<Card.Meta>


</Card.Meta>
<Row gutter={24}>
<Col span={8}>
<Statistic title="Size" value={prettierBytes(n(bucketInfo.size))}/>
</Col>
<Col span={6}>
<Statistic title="Entries" value={n(bucketInfo.entryCount)}/>
</Col>
<Col span={10}>
<Statistic title="History" value={bucketInfo.entryCount > 0n ? getHistory(bucketInfo) : "---"}/>
</Col>
</Row>
<RemoveConfirmationByName name={bucketInfo.name} onRemoved={onRemoved}
onCanceled={() => setConfirmRemove(false)} confirm={confirmRemove}
resourceType="bucket"/>
<Modal title="Settings" open={changeSettings} footer={null}
onCancel={() => setChangeSettings(false)}>
<CreateOrUpdate client={client} key={bucketInfo.name}
bucketName={bucketInfo.name}
readOnly={readOnly}
onCreated={() => setChangeSettings(false)}/>
</Modal>
</Card>
</Card.Meta>
<Row gutter={24}>
<Col span={8}>
<Statistic title="Size" value={prettierBytes(bigintToNumber(bucketInfo.size))} />
</Col>
<Col span={6}>
<Statistic title="Entries" value={bigintToNumber(bucketInfo.entryCount)} />
</Col>
<Col span={10}>
<Statistic title="History" value={bucketInfo.entryCount > 0n ? getHistory(bucketInfo) : "---"} />
</Col>
</Row>
<RemoveConfirmationByName name={bucketInfo.name} onRemoved={onRemoved}
onCanceled={() => setConfirmRemove(false)} confirm={confirmRemove}
resourceType="bucket" />
<Modal title="Settings" open={changeSettings} footer={null}
onCancel={() => setChangeSettings(false)}>
<CreateOrUpdate client={client} key={bucketInfo.name}
bucketName={bucketInfo.name}
readOnly={readOnly}
onCreated={() => setChangeSettings(false)} />
</Modal>
</Card>
);

}
23 changes: 23 additions & 0 deletions src/Components/LicenseAlert/LicenseAlert.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from "react";
import {render, screen} from "@testing-library/react";
import LicenseAlert from "./LicenseAlert";

describe("LicenseAlert", () => {
it("renders without crashing", () => {
render(<LicenseAlert />);
expect(screen.getByRole("alert")).toBeInTheDocument();
});

it("contains the correct links", () => {
render(<LicenseAlert />);
const buslLink = screen.getByRole("link", {name: /Business Source License \(BUSL\)/i});
expect(buslLink).toHaveAttribute("href", "https://github.com/reductstore/reductstore/blob/main/LICENSE");
expect(buslLink).toHaveAttribute("target", "_blank");
expect(buslLink).toHaveAttribute("rel", "noopener noreferrer");

const pricingPageLink = screen.getByRole("link", {name: /pricing page/i});
expect(pricingPageLink).toHaveAttribute("href", "https://www.reduct.store/pricing");
expect(pricingPageLink).toHaveAttribute("target", "_blank");
expect(pricingPageLink).toHaveAttribute("rel", "noopener noreferrer");
});
});
26 changes: 26 additions & 0 deletions src/Components/LicenseAlert/LicenseAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from "react";
import {Alert} from "antd";

interface LicenseAlertProps {
alertMessage?: React.ReactNode;
}

const LicenseAlert: React.FC<LicenseAlertProps> = ({alertMessage}) => (
<Alert
type="warning"
message={
alertMessage || (
<span>
Please review the <strong>
<a href="https://github.com/reductstore/reductstore/blob/main/LICENSE" target="_blank" rel="noopener noreferrer">
Business Source License (BUSL)
</a></strong> for ReductStore and consult our <strong>
<a href="https://www.reduct.store/pricing" target="_blank" rel="noopener noreferrer">
pricing page
</a></strong> for more information on licensing options.
</span>
)}
/>
);

export default LicenseAlert;
15 changes: 15 additions & 0 deletions src/Components/LicenseDetails/LicenseDetails.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.licenseDescriptions .ant-descriptions-item-label {
font-weight: bold;
}

.overQuota .ant-descriptions-item-content {
color: red;
}

.expired .ant-descriptions-item-content {
color: red;
}

.licenseAlert {
margin-bottom: 20px;
}
94 changes: 94 additions & 0 deletions src/Components/LicenseDetails/LicenseDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React from "react";
import {render, screen} from "@testing-library/react";
import LicenseDetails from "./LicenseDetails";
import {LicenseInfo} from "reduct-js";
import {mockJSDOM} from "../../Helpers/TestHelpers";

describe("LicenseDetails", () => {
const mockLicenseInfo: LicenseInfo = {
plan: "Pro",
licensee: "Acme Corp",
invoice: "INV-123",
expiryDate: 1735689600000,
deviceNumber: 100,
diskQuota: 1,
fingerprint: "unique-fingerprint"
};

const mockLicenseInfoExpired: LicenseInfo = {
plan: "Pro",
licensee: "Acme Corp",
invoice: "INV-123",
expiryDate: 1614556800000,
deviceNumber: 100,
diskQuota: 1,
fingerprint: "unique-fingerprint"
};

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

it("renders without crashing", () => {
render(<LicenseDetails license={mockLicenseInfo} usage={0n} />);
expect(screen.getByText("Plan")).toBeInTheDocument();
});

it("renders license details correctly", () => {
render(<LicenseDetails license={mockLicenseInfo} usage={0n} />);
const expiryDate = new Date(mockLicenseInfo.expiryDate).toLocaleDateString(
undefined,
{
year: "numeric",
month: "long",
day: "numeric",
}
);

expect(screen.getByText("Plan")).toBeInTheDocument();
expect(screen.getByText("Pro")).toBeInTheDocument();

expect(screen.getByText("Licensee")).toBeInTheDocument();
expect(screen.getByText("Acme Corp")).toBeInTheDocument();

expect(screen.getByText("Invoice")).toBeInTheDocument();
expect(screen.getByText("INV-123")).toBeInTheDocument();

expect(screen.getByText("Expiry Date")).toBeInTheDocument();
expect(screen.getByText(expiryDate)).toBeInTheDocument();

expect(screen.getByText("Device Number")).toBeInTheDocument();
expect(screen.getByText("100")).toBeInTheDocument();

expect(screen.getByText("Disk Quota")).toBeInTheDocument();
expect(screen.getByText(/^1 TB/)).toBeInTheDocument();

expect(screen.getByText("Fingerprint")).toBeInTheDocument();
expect(screen.getByText("unique-fingerprint")).toBeInTheDocument();
});

it("does not render license alert when license is valid and disk quota is not exceeded", () => {
render(<LicenseDetails license={mockLicenseInfo} usage={0n} />);
const regex = /Your license has expired\./i;
expect(screen.queryByText(regex)).not.toBeInTheDocument();
});

it("renders license alert when license has expired", () => {
render(<LicenseDetails license={mockLicenseInfoExpired} usage={0n} />);
const regex = /Your license has expired\./i;
expect(screen.getByText(regex)).toBeInTheDocument();
});

it("renders license alert when disk quota exceeded", () => {
render(<LicenseDetails license={mockLicenseInfo} usage={BigInt(1e12 + 1)} />);
const regex = /disk quota has been exceeded\./i;
expect(screen.getByText(regex)).toBeInTheDocument();
});

it("renders license alert when license has expired and disk quota exceeded", () => {
render(<LicenseDetails license={mockLicenseInfoExpired} usage={2n} />);
const regex = /Your license has expired\./i;
expect(screen.getByText(regex)).toBeInTheDocument();
});
});
55 changes: 55 additions & 0 deletions src/Components/LicenseDetails/LicenseDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from "react";
import {Descriptions} from "antd";
import {LicenseInfo} from "reduct-js";
import "./LicenseDetails.css";
import LicenseAlert from "../LicenseAlert/LicenseAlert";
// @ts-ignore
import prettierBytes from "prettier-bytes";
import {bigintToNumber} from "../../Helpers/NumberUtils";
import {checkLicenseStatus} from "../../Helpers/licenseUtils";

interface LicenseDetailsProps {
license: LicenseInfo;
usage: bigint;
}

const LicenseDetails: React.FC<LicenseDetailsProps> = ({license, usage}) => {
const {isValid, hasExpired, isOverQuota, usageInTB} = checkLicenseStatus(license, usage);
const isUnlimited = license.diskQuota === 0;
const usagePercentage = isUnlimited ? "N/A" : `${((usageInTB / Number(license.diskQuota)) * 100).toFixed(2)}%`;
const expiryDate = new Date(license.expiryDate).toLocaleDateString(undefined,
{
year: "numeric",
month: "long",
day: "numeric",
}
);
const alertMessage = (
<span>
Your license {hasExpired ? "has expired" : "disk quota has been exceeded"}.
Please <strong><a href="https://www.reduct.store/contact" target="_blank" rel="noopener noreferrer">contact us</a>
</strong> to {hasExpired ? "renew your license" : "increase your disk quota"} at your earliest convenience.
</span>
);
return (
<>
{!isValid && <div className="licenseAlert"><LicenseAlert alertMessage={alertMessage} /></div>}
<Descriptions size="default" column={1} className="licenseDescriptions">
<Descriptions.Item label="Licensee">{license.licensee}</Descriptions.Item>
<Descriptions.Item label="Plan">{license.plan}</Descriptions.Item>
<Descriptions.Item label="Invoice">{license.invoice}</Descriptions.Item>
<Descriptions.Item label="Device Number">{license.deviceNumber}</Descriptions.Item>
<Descriptions.Item label="Expiry Date" className={hasExpired ? "expired" : ""}
>{expiryDate}</Descriptions.Item>
<Descriptions.Item label="Disk Quota" className={isOverQuota ? "overQuota" : ""}>
{isUnlimited ? "Unlimited" :
`${license.diskQuota} TB (Used: ${prettierBytes(bigintToNumber(usage))}, ${usagePercentage} of quota)`}
</Descriptions.Item>
<Descriptions.Item label="Fingerprint">{license.fingerprint}</Descriptions.Item>

</Descriptions>
</>
);
};

export default LicenseDetails;
11 changes: 4 additions & 7 deletions src/Components/Replication/ReplicationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {DeleteOutlined, SettingOutlined} from "@ant-design/icons";
import "./ReplicationCard.css";
import CreateOrUpdate from "./CreateOrUpdate";
import RemoveConfirmationByName from "../RemoveConfirmationByName";
import {bigintToNumber} from "../../Helpers/NumberUtils";

interface Props {
replication: FullReplicationInfo;
Expand All @@ -24,10 +25,6 @@ export default function ReplicationCard(props: Readonly<Props>) {
const {client, replication, index} = props;
const {info, diagnostics} = replication;

const n = (big: BigInt) => {
return Number(big.valueOf());
};

const onRemoved = async () => {
await client.deleteReplication(info.name);
props.onRemoved(info.name);
Expand Down Expand Up @@ -62,13 +59,13 @@ export default function ReplicationCard(props: Readonly<Props>) {
</Card.Meta>
<Row gutter={24}>
<Col span={8}>
<Statistic title="Records Awaiting Replication" value={n(info.pendingRecords)} />
<Statistic title="Records Awaiting Replication" value={bigintToNumber(info.pendingRecords)} />
</Col>
<Col span={8}>
<Statistic title="Successfully Replicated (Past Hour)" value={n(diagnostics.hourly.ok)} />
<Statistic title="Successfully Replicated (Past Hour)" value={bigintToNumber(diagnostics.hourly.ok)} />
</Col>
<Col span={8}>
<Statistic title="Errors (Past Hour)" value={n(diagnostics.hourly.errored)} />
<Statistic title="Errors (Past Hour)" value={bigintToNumber(diagnostics.hourly.errored)} />
</Col>
</Row>
<RemoveConfirmationByName name={info.name} onRemoved={onRemoved}
Expand Down
Loading

0 comments on commit 2ede59f

Please sign in to comment.