Create availability

This commit is contained in:
Arnaud 2024-09-20 19:40:44 +02:00
parent 4c60f36568
commit 508977120d
No known key found for this signature in database
GPG Key ID: 69D6CE281FCAE663
16 changed files with 615 additions and 30 deletions

28
package-lock.json generated
View File

@ -9,8 +9,8 @@
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"@codex-storage/marketplace-ui-components": "0.0.4",
"@codex-storage/sdk-js": "0.0.3",
"@codex-storage/marketplace-ui-components": "0.0.5",
"@codex-storage/sdk-js": "0.0.4",
"@sentry/react": "^8.27.0",
"@tanstack/react-query": "^5.51.15",
"@tanstack/react-router": "^1.45.7",
@ -33,7 +33,7 @@
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"prettier": "^3.3.3",
"typescript": "^5.2.2",
"typescript": "^5.5.2",
"vite": "^5.3.4"
},
"engines": {
@ -343,31 +343,34 @@
}
},
"node_modules/@codex-storage/marketplace-ui-components": {
"version": "0.0.4",
"license": "MIT",
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@codex-storage/marketplace-ui-components/-/marketplace-ui-components-0.0.5.tgz",
"integrity": "sha512-YrT8glXA6Dt/gsgyALzhHYBVdCFlVFRaahkBAwvVV7rpnvh7eeIdObSq6+m4kyEGALLIh/wVZ4OIfD1bG0dXOQ==",
"dependencies": {
"lucide-react": "^0.428.0"
"lucide-react": "^0.441.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@codex-storage/sdk-js": "0.0.3",
"@codex-storage/sdk-js": "0.0.4",
"@tanstack/react-query": "^5.51.24",
"react": "^18.3.1",
"react-dom": "^18.3.1"
}
},
"node_modules/@codex-storage/marketplace-ui-components/node_modules/lucide-react": {
"version": "0.428.0",
"license": "ISC",
"version": "0.441.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.441.0.tgz",
"integrity": "sha512-0vfExYtvSDhkC2lqg0zYVW1Uu9GsI4knuV9GP9by5z0Xhc4Zi5RejTxfz9LsjRmCyWVzHCJvxGKZWcRyvQCWVg==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/@codex-storage/sdk-js": {
"version": "0.0.3",
"license": "MIT",
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@codex-storage/sdk-js/-/sdk-js-0.0.4.tgz",
"integrity": "sha512-RKhghVmVDSbDKrof5D89KOkif/mhNy87oYPrguC04W0OChBxFoDTFeuWmEbL3e9mVhOObGoLRB6dMkFBEup2vw==",
"dependencies": {
"valibot": "^0.36.0"
},
@ -3530,7 +3533,8 @@
},
"node_modules/valibot": {
"version": "0.36.0",
"license": "MIT"
"resolved": "https://registry.npmjs.org/valibot/-/valibot-0.36.0.tgz",
"integrity": "sha512-CjF1XN4sUce8sBK9TixrDqFM7RwNkuXdJu174/AwmQUB62QbCQADg5lLe8ldBalFgtj1uKj+pKwDJiNo4Mn+eQ=="
},
"node_modules/vite": {
"version": "5.4.5",

View File

@ -23,8 +23,8 @@
"React"
],
"dependencies": {
"@codex-storage/marketplace-ui-components": "0.0.4",
"@codex-storage/sdk-js": "0.0.3",
"@codex-storage/marketplace-ui-components": "0.0.5",
"@codex-storage/sdk-js": "0.0.4",
"@sentry/react": "^8.27.0",
"@tanstack/react-query": "^5.51.15",
"@tanstack/react-router": "^1.45.7",
@ -47,7 +47,7 @@
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"prettier": "^3.3.3",
"typescript": "^5.2.2",
"typescript": "^5.5.2",
"vite": "^5.3.4"
},
"engines": {

View File

@ -0,0 +1,5 @@
.availabilitConfirm-title {
margin-left: auto;
margin-right: auto;
margin-bottom: 1rem;
}

View File

@ -0,0 +1,60 @@
import {
SpaceAllocation,
StepperAction,
} from "@codex-storage/marketplace-ui-components";
import { Dispatch, useEffect } from "react";
import "./AvailabilityForm.css";
import { CodexNodeSpace } from "@codex-storage/sdk-js";
import { UIAvailability } from "./types";
import { GB, TB } from "../../utils/constants";
import "./AvailabilityConfirm.css";
type Props = {
dispatch: Dispatch<StepperAction>;
space: CodexNodeSpace;
availability: UIAvailability;
enableNext?: boolean;
};
export function AvailabilityConfirm({
availability,
dispatch,
space,
enableNext = true,
}: Props) {
useEffect(() => {
if (enableNext) {
dispatch({ type: "toggle-next", isNextEnable: true });
}
}, [dispatch, enableNext]);
const unit = availability.totalSizeUnit === "gb" ? GB : TB;
const { quotaMaxBytes, quotaReservedBytes } = space;
const size = availability.totalSize * unit;
const isUpdating = !!availability.id;
const allocated = isUpdating ? quotaReservedBytes - size : quotaReservedBytes;
const remaining = quotaMaxBytes - allocated - size;
const spaceData = [
{
title: "Space allocated",
size: allocated,
},
{
title: "New space allocation",
size: size,
},
{
title: "Remaining space",
size: remaining < 0 ? 0 : remaining,
},
];
return (
<>
<b className="availabilitConfirm-title">Node space allocation</b>
<SpaceAllocation data={spaceData} />
</>
);
}

View File

@ -0,0 +1,132 @@
import {
Stepper,
Toast,
useStepperReducer,
Button,
Modal,
} from "@codex-storage/marketplace-ui-components";
import { useEffect, useRef, useState } from "react";
import { AvailabilityForm } from "./AvailabilityForm";
import { Plus } from "lucide-react";
import { CodexNodeSpace } from "@codex-storage/sdk-js";
import { AvailabilityConfirm } from "./AvailabilityConfirmation";
import { WebStorage } from "../../utils/web-storage";
import { UIAvailability } from "./types";
import { STEPPER_DURATION } from "../../utils/constants";
import { useAvailabilityMutation } from "./useAvailabilityMutation";
import { AvailabilityDone } from "./AvailabilityDone";
type Props = {
space: CodexNodeSpace;
};
const CONFIRM_STATE = 2;
export function AvailabilityCreate({ space }: Props) {
const components = [AvailabilityForm, AvailabilityConfirm, AvailabilityDone];
const steps = useRef(["Availability", "Confirmation", "Success"]);
const { state, dispatch } = useStepperReducer(components.length);
const [availability, setAvailability] = useState<UIAvailability>({
totalSize: 1,
duration: 1,
minPrice: 0,
maxCollateral: 0,
totalSizeUnit: "gb",
durationUnit: "days",
});
useEffect(() => {
Promise.all([
WebStorage.get<number>("availability-step"),
WebStorage.get<UIAvailability>("availability"),
]).then(([s, a]) => {
if (s) {
dispatch({
type: "next",
step: s,
});
}
if (a) {
setAvailability(a);
}
// TODO validationb
dispatch({
type: "toggle-next",
isNextEnable: true,
});
});
}, [dispatch]);
const { mutateAsync, toast } = useAvailabilityMutation(dispatch, state);
const onNextStep = async (step: number) => {
WebStorage.set("availability-step", step);
if (step == CONFIRM_STATE) {
mutateAsync(availability);
} else {
dispatch({
step,
type: "next",
});
}
};
const onAvailabilityChange = (
data: Partial<UIAvailability>,
valid: boolean
) => {
const val = { ...availability, ...data };
if (valid) {
WebStorage.set("availability", val);
}
setAvailability(val);
};
const onOpen = () =>
dispatch({
type: "open",
});
const onClose = () => dispatch({ type: "close" });
const Body = components[state.step] || (() => <span />);
const backLabel = state.step ? "Back" : "Close";
const nextLabel = state.step === steps.current.length - 1 ? "Finish" : "Next";
return (
<>
<Button
label="Availability"
Icon={Plus}
onClick={onOpen}
variant="primary"
/>
<Modal open={state.open} onClose={onClose} displayCloseButton={false}>
<Stepper
titles={steps.current}
state={state}
dispatch={dispatch}
duration={STEPPER_DURATION}
onNextStep={onNextStep}
backLabel={backLabel}
nextLabel={nextLabel}>
<Body
dispatch={dispatch}
state={state}
onAvailabilityChange={onAvailabilityChange}
availability={availability}
space={space}
/>
</Stepper>
</Modal>
<Toast message={toast.message} time={toast.time} variant="error" />
</>
);
}

View File

@ -0,0 +1,7 @@
.availabilityDone {
margin: auto;
}
.availabilityDone-icon {
color: var(--codex-color-primary);
}

View File

@ -0,0 +1,28 @@
import {
Placeholder,
StepperAction,
} from "@codex-storage/marketplace-ui-components";
import { CircleCheck } from "lucide-react";
import { Dispatch, useEffect } from "react";
import "./AvailabilityDone.css";
type Props = {
dispatch: Dispatch<StepperAction>;
};
export function AvailabilityDone({ dispatch }: Props) {
useEffect(() => {
dispatch({
isNextEnable: true,
type: "toggle-next",
});
}, [dispatch]);
return (
<Placeholder
Icon={<CircleCheck size="4rem" className="availabilityDone-icon" />}
className="availabilityDone"
title="Your availability is created."
message="The new availability will appear in your availability list. You can safely close this dialog."></Placeholder>
);
}

View File

@ -0,0 +1,19 @@
.availabilityForm-itemInput {
width: 100%;
}
.availabilityForm-item {
margin-bottom: 1rem;
}
.availabilityForm-item--error .input,
.availabilityForm-item--error .inputGroup-helper,
.availabilityForm-item--error .inputGroup-select {
color: rgb(var(--codex-color-error));
border-color: rgb(var(--codex-color-error));
}
.availabilityForm-row {
display: flex;
gap: 0.5rem;
}

View File

@ -0,0 +1,197 @@
import {
Input,
InputGroup,
StepperAction,
StepperState,
} from "@codex-storage/marketplace-ui-components";
import { ChangeEvent, Dispatch, useState } from "react";
import "./AvailabilityForm.css";
import { CodexNodeSpace } from "@codex-storage/sdk-js";
import { UIAvailability } from "./types";
import { GB, TB } from "../../utils/constants";
import { classnames } from "../../utils/classnames";
import { AvailabilityConfirm } from "./AvailabilityConfirmation";
type Props = {
dispatch: Dispatch<StepperAction>;
state: StepperState;
space: CodexNodeSpace;
onAvailabilityChange: (data: Partial<UIAvailability>, valid: boolean) => void;
availability: UIAvailability;
};
export function AvailabilityForm({
dispatch,
onAvailabilityChange,
availability,
space,
}: Props) {
const [totalSizeError, setTotalSizeError] = useState("");
const onTotalSizeUnitChange = async (e: ChangeEvent<HTMLSelectElement>) => {
const element = e.currentTarget;
const valid = element.value === "tb" || element.value === "gb";
dispatch({
type: "toggle-next",
isNextEnable: false,
});
onAvailabilityChange(
{
totalSize: 0,
totalSizeUnit: element.value as "tb" | "gb",
},
valid
);
};
const onDurationUnitChange = async (e: ChangeEvent<HTMLSelectElement>) => {
const element = e.currentTarget;
const valid =
element.value === "hours" ||
element.value === "days" ||
element.value === "months";
onAvailabilityChange(
{
duration: 1,
durationUnit: element.value as "hours" | "days" | "months",
},
valid
);
};
const onAvailablityChange = async (e: ChangeEvent<HTMLInputElement>) => {
const element = e.currentTarget;
const valid = element.checkValidity();
const val = parseFloat(element.value);
const unit = availability.totalSizeUnit === "gb" ? GB : TB;
if (val * unit > space.quotaMaxBytes - space.quotaReservedBytes) {
setTotalSizeError(
"You cannot allocate more space than the remaining space."
);
dispatch({
type: "toggle-next",
isNextEnable: false,
});
return;
}
onAvailabilityChange(
{
[element.name]: parseFloat(element.value),
},
valid
);
setTotalSizeError(valid ? "" : element.validationMessage);
dispatch({
type: "toggle-next",
isNextEnable: valid && parseFloat(e.target.value) > 0,
});
};
const onInputChange = async (e: ChangeEvent<HTMLInputElement>) => {
const element = e.currentTarget;
const valid = element.checkValidity();
onAvailabilityChange(
{
[element.name]: parseFloat(element.value),
},
valid
);
};
const unit = availability.totalSizeUnit === "gb" ? GB : TB;
const max = space.quotaMaxBytes / unit - space.quotaReservedBytes / unit;
return (
<>
<AvailabilityConfirm
availability={availability}
dispatch={dispatch}
space={space}
enableNext={false}
/>
<InputGroup
id="totalSize"
name="totalSize"
type="number"
label="Total size"
helper={
totalSizeError || "Total size of availability's storage in bytes"
}
className={classnames(
["availabilityForm-item"],
["availabilityForm-item--error", !!totalSizeError]
)}
inputClassName="availabilityForm-itemInput"
min={0.01}
max={max}
onChange={onAvailablityChange}
onGroupChange={onTotalSizeUnitChange}
value={availability.totalSize.toString()}
step={"0.01"}
group={[
["gb", "GB"],
["tb", "TB"],
]}
groupValue={availability.totalSizeUnit}
/>
<div className="availabilityForm-item">
<InputGroup
id="duration"
name="duration"
type="number"
label="Duration"
helper="The duration of the request in seconds"
inputClassName="availabilityForm-itemInput"
min={1}
onChange={onInputChange}
onGroupChange={onDurationUnitChange}
group={[
["hours", "Hours"],
["days", "Days"],
["months", "Months"],
]}
value={availability.duration.toString()}
groupValue={availability.durationUnit}
/>
</div>
<div className="availabilityForm-row">
<div className="availabilityForm-item">
<Input
id="minPrice"
name="minPrice"
type="number"
label="Min price"
min={0}
helper="Minimum price to be paid (in amount of tokens)"
inputClassName="availabilityForm-itemInput"
onChange={onInputChange}
value={availability.minPrice.toString()}
/>
</div>
<div className="availabilityForm-item">
<Input
id="maxCollateral"
name="maxCollateral"
type="number"
label="Max collateral"
min={0}
helper="Maximum collateral user is willing to pay per filled Slot (in amount of tokens)"
inputClassName="availabilityForm-itemInput"
onChange={onInputChange}
value={availability.maxCollateral.toString()}
/>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,9 @@
export type UIAvailability = {
id?: string;
totalSize: number;
duration: number;
durationUnit: "hours" | "days" | "months";
minPrice: number;
maxCollateral: number;
totalSizeUnit: "gb" | "tb";
};

View File

@ -0,0 +1,76 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CodexSdk } from "../../proxy";
import { GB, TB } from "../../utils/constants";
import { Promises } from "../../utils/promises";
import { WebStorage } from "../../utils/web-storage";
import { UIAvailability } from "./types";
import { Dispatch, useState } from "react";
import {
StepperAction,
StepperState,
} from "@codex-storage/marketplace-ui-components";
import * as Sentry from "@sentry/browser";
import { SafeValue } from "@codex-storage/sdk-js/async";
import { Times } from "../../utils/times";
export function useAvailabilityMutation(
dispatch: Dispatch<StepperAction>,
state: StepperState
) {
const queryClient = useQueryClient();
const [toast, setToast] = useState({
time: 0,
message: "",
});
const { mutateAsync } = useMutation({
mutationKey: ["debug"],
mutationFn: ({
totalSize,
totalSizeUnit,
duration,
durationUnit = "days",
...input
}: UIAvailability) => {
const unit = totalSizeUnit === "gb" ? GB : TB;
const marketplace = CodexSdk.marketplace;
const time = Times.toMs(duration, durationUnit);
const fn: (
input: Omit<UIAvailability, "totalSizeUnit" | "durationUnit">
) => Promise<SafeValue<unknown>> = input.id
? (input) =>
marketplace.updateAvailability({ ...input, id: input.id || "" })
: (input) => marketplace.createAvailability(input);
return fn({
...input,
duration: time,
totalSize: Math.trunc(totalSize * unit),
}).then((s) => Promises.rejectOnError(s));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["availabilities"] });
queryClient.invalidateQueries({ queryKey: ["space"] });
WebStorage.delete("availability");
dispatch({
type: "next",
step: state.step,
});
},
onError: (error) => {
if (import.meta.env.PROD) {
Sentry.captureException(error);
}
setToast({
message: "Error when trying to update: " + error.message,
time: Date.now(),
});
},
});
return { mutateAsync, toast };
}

View File

@ -29,17 +29,14 @@ export function NodeSpaceAllocation() {
data={[
{
title: "Maximum storage space used by the node",
percent: 60,
size: quotaMaxBytes,
},
{
title: "Amount of storage space currently in use",
percent: (quotaUsedBytes / quotaMaxBytes) * 100,
size: quotaUsedBytes,
},
{
title: "Amount of storage space reserved",
percent: (quotaReservedBytes / quotaMaxBytes) * 100,
size: quotaReservedBytes,
},
]}></SpaceAllocation>

View File

@ -1,15 +1,43 @@
import { EmptyPlaceholder } from "@codex-storage/marketplace-ui-components";
import { Placeholder } from "@codex-storage/marketplace-ui-components";
import { createFileRoute } from "@tanstack/react-router";
import "./availabilities.css";
import { CircleX } from "lucide-react";
import { AvailabilityCreate } from "../../components/Availability/AvailabilityCreate";
import { Promises } from "../../utils/promises";
import { CodexSdk } from "../../sdk/codex";
import { useQuery } from "@tanstack/react-query";
export const Route = createFileRoute("/dashboard/availabilities")({
component: () => {
const {
data = {
quotaMaxBytes: 0,
quotaReservedBytes: 0,
quotaUsedBytes: 0,
totalBlocks: 0,
},
isError,
error,
} = useQuery({
queryFn: () =>
CodexSdk.data.space().then((s) => Promises.rejectOnError(s)),
queryKey: ["space"],
});
if (isError) {
return (
<Placeholder
Icon={<CircleX size={"4em"}></CircleX>}
title="Something went wrong"
message={error.message}></Placeholder>
);
}
return (
<div className="availabilities">
<EmptyPlaceholder
title="Nothing to show"
message="This page is in progress."
/>
<div className="container">
<div className="availabilities-actions">
<AvailabilityCreate space={data} />
</div>
</div>
);
},

View File

@ -1,8 +1,8 @@
import { Codex } from "@codex-storage/sdk-js";
import { WebStorage } from "../utils/web-storage";
let client: Codex;
let url: string;
let client: Codex = new Codex(import.meta.env.VITE_CODEX_API_URL);
let url: string = import.meta.env.VITE_CODEX_API_URL;
export const CodexSdk = {
url() {
@ -10,19 +10,20 @@ export const CodexSdk = {
},
load() {
return WebStorage.get<string>("codex-node-url").then((url) => {
url = url || import.meta.env.VITE_CODEX_API_URL;
return WebStorage.get<string>("codex-node-url").then((u) => {
url = u || import.meta.env.VITE_CODEX_API_URL;
client = new Codex(url);
});
},
updateURL(url: string) {
url = url;
updateURL(u: string) {
url = u;
client = new Codex(url);
return WebStorage.set("codex-node-url", url);
},
// TODO Change this
get debug() {
return client.debug;
},

View File

@ -9,3 +9,7 @@ export const ICON_SIZE = "1.25rem";
export const STEPPER_DURATION = 500;
export const EXPLORER_URL = "https://explorer.testnet.codex.storage/tx";
export const GB = 1_073_741_824;
export const TB = 1_099_511_627_776;

18
src/utils/times.ts Normal file
View File

@ -0,0 +1,18 @@
export type TimeUnit = "days" | "months" | "years" | "minutes" | "hours";
export const Times = {
toMs(value: number, unit: TimeUnit) {
switch (unit) {
case "minutes":
return 60 * value;
case "hours":
return 60 * 60 * value;
case "days":
return 24 * 60 * 60 * value;
case "months":
return 30 * 24 * 60 * 60 * value;
case "years":
return 365 * 24 * 60 * 60 * value;
}
},
};