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-180: Web console shows license information #64

Merged
merged 15 commits into from
Mar 27, 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
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
Loading