feat: backend api

This commit is contained in:
2024-11-26 15:29:18 +00:00
parent daa640068b
commit 3c7736dae7
34 changed files with 1147 additions and 697 deletions

View File

@ -1,15 +1,25 @@
import classNames from "classnames";
import { forwardRef, HTMLProps } from "react";
export type AsyncButtonProps = {
onClick?: (e: React.MouseEvent) => Promise<void>;
onClick?: (e: React.MouseEvent) => Promise<void> | void;
} & Omit<HTMLProps<HTMLButtonElement>, "type" | "ref" | "onClick">;
const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>(
function AsyncButton(props, ref) {
function AsyncButton({ className, ...props }, ref) {
const hasBg = className?.includes("bg-");
return (
<button
ref={ref}
className="bg-slate-700 py-1 px-2 rounded-xl"
className={classNames(
"py-1 px-2 rounded-xl font-medium",
{
"bg-neutral-800 cursor-not-allowed text-neutral-500":
!hasBg && props.disabled === true,
"bg-neutral-900": !hasBg && !props.disabled,
},
className,
)}
{...props}
>
{props.children}

View File

@ -1,22 +1,20 @@
import { CostInterval, MachineSpec } from "../api";
import { VmCostPlan } from "../api";
export default function CostLabel({ cost }: { cost: MachineSpec["cost"] }) {
function intervalName(n: number) {
export default function CostLabel({ cost }: { cost: VmCostPlan }) {
function intervalName(n: string) {
switch (n) {
case CostInterval.Hour:
return "Hour";
case CostInterval.Day:
case "day":
return "Day";
case CostInterval.Month:
case "month":
return "Month";
case CostInterval.Year:
case "year":
return "Year";
}
}
return (
<>
{cost.count} {cost.currency}/{intervalName(cost.interval)}
{cost.amount} {cost.currency}/{intervalName(cost.interval_type)}
</>
);
}

26
src/components/icon.tsx Normal file
View File

@ -0,0 +1,26 @@
import { MouseEventHandler } from "react";
import Icons from "../icons.svg";
type Props = {
name: string;
size?: number;
className?: string;
onClick?: MouseEventHandler<SVGSVGElement>;
};
export function Icon(props: Props) {
const size = props.size || 20;
const href = `${Icons}#${props.name}`;
return (
<svg
width={size}
height={size}
className={props.className}
onClick={props.onClick}
>
<use href={href} />
</svg>
);
}

View File

@ -5,6 +5,7 @@ import { loginNip7 } from "../login";
import useLogin from "../hooks/login";
import Profile from "./profile";
import { NostrLink } from "@snort/system";
import { Link } from "react-router-dom";
export default function LoginButton() {
const system = useContext(SnortContext);
@ -19,6 +20,8 @@ export default function LoginButton() {
Sign In
</AsyncButton>
) : (
<Profile link={NostrLink.publicKey(login.pubkey)} />
<Link to="/account">
<Profile link={NostrLink.publicKey(login.pubkey)} />
</Link>
);
}

85
src/components/modal.tsx Normal file
View File

@ -0,0 +1,85 @@
import classNames from "classnames";
import React, { ReactNode, useEffect } from "react";
import { createPortal } from "react-dom";
import { AsyncButton } from "./button";
export interface ModalProps {
id: string;
className?: string;
bodyClassName?: string;
onClose?: (e: React.MouseEvent | KeyboardEvent) => void;
onClick?: (e: React.MouseEvent) => void;
children?: ReactNode;
showClose?: boolean;
ready?: boolean;
largeModal?: boolean;
}
export default function Modal(props: ModalProps) {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && props.onClose) {
props.onClose(e);
}
};
useEffect(() => {
document.body.classList.add("scroll-lock");
document.addEventListener("keydown", handleKeyDown);
return () => {
document.body.classList.remove("scroll-lock");
document.removeEventListener("keydown", handleKeyDown);
};
}, []);
const handleBackdropClick = (e: React.MouseEvent) => {
e.stopPropagation();
props.onClose?.(e);
};
return createPortal(
<div
className={classNames(
"z-[42] w-screen h-screen top-0 left-0 fixed bg-black/80 flex justify-center overflow-y-auto",
)}
onMouseDown={handleBackdropClick}
onClick={(e) => {
e.stopPropagation();
}}
>
<div
className={
props.bodyClassName ??
classNames(
"relative bg-layer-1 p-8 transition max-xl:rounded-t-3xl xl:rounded-3xl max-xl:mt-auto xl:my-auto max-lg:w-full",
{
"max-xl:-translate-y-[calc(100vh-100dvh)]": props.ready ?? true,
"max-xl:translate-y-[50vh]": !(props.ready ?? true),
"lg:w-[50vw]": !(props.largeModal ?? false),
"lg:w-[80vw]": props.largeModal ?? false,
},
)
}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
props.onClick?.(e);
}}
>
{(props.showClose ?? true) && (
<div className="absolute right-4 top-4">
<AsyncButton
onClick={async (e) => {
e.stopPropagation();
props.onClose?.(e);
}}
className="rounded-full aspect-square bg-layer-2 p-3"
/>
</div>
)}
{props.children}
</div>
</div>,
document.body,
) as JSX.Element;
}

View File

@ -0,0 +1,9 @@
import { VmOsImage } from "../api";
export default function OsImageName({ image }: { image: VmOsImage }) {
return (
<span>
{image.distribution} {image.version}
</span>
);
}

View File

@ -1,75 +0,0 @@
.btcpay-form {
display: inline-flex;
align-items: center;
justify-content: center;
}
.btcpay-form--inline {
flex-direction: row;
}
.btcpay-form--block {
flex-direction: column;
}
.btcpay-form--inline .submit {
margin-left: 15px;
}
.btcpay-form--block select {
margin-bottom: 10px;
}
.btcpay-form .btcpay-custom-container {
text-align: center;
}
.btcpay-custom {
display: flex;
align-items: center;
justify-content: center;
}
.btcpay-form .plus-minus {
cursor: pointer;
font-size: 25px;
line-height: 25px;
background: #dfe0e1;
height: 30px;
width: 45px;
border: none;
border-radius: 60px;
margin: auto 5px;
display: inline-flex;
justify-content: center;
}
.btcpay-form select {
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
color: currentColor;
background: transparent;
border: 1px solid transparent;
display: block;
padding: 1px;
margin-left: auto;
margin-right: auto;
font-size: 11px;
cursor: pointer;
}
.btcpay-form select:hover {
border-color: #ccc;
}
.btcpay-form option {
color: #000;
background: rgba(0, 0, 0, 0.1);
}
.btcpay-input-price {
-moz-appearance: textfield;
border: none;
box-shadow: none;
text-align: center;
font-size: 25px;
margin: auto;
border-radius: 5px;
line-height: 35px;
background: #fff;
}
.btcpay-input-price::-webkit-outer-spin-button,
.btcpay-input-price::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}

View File

@ -1,6 +1,8 @@
import { MachineSpec } from "../api";
import "./pay-button.css";
import { ReactNode } from "react";
import { VmTemplate } from "../api";
import useLogin from "../hooks/login";
import { AsyncButton } from "./button";
import { useNavigate } from "react-router-dom";
declare global {
interface Window {
@ -10,55 +12,33 @@ declare global {
}
}
export default function VpsPayButton({ spec }: { spec: MachineSpec }) {
const serverUrl = "https://btcpay.v0l.io/api/v1/invoices";
export default function VpsPayButton({ spec }: { spec: VmTemplate }) {
const login = useLogin();
const classNames =
"w-full text-center text-lg uppercase rounded-xl py-3 font-bold cursor-pointer select-none";
const navigte = useNavigate();
function handleFormSubmit(event: React.FormEvent) {
event.preventDefault();
const form = event.target as HTMLFormElement;
const xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function () {
if (this.readyState == 4 && this.status == 200 && this.responseText) {
window.btcpay?.appendInvoiceFrame(
JSON.parse(this.responseText).invoiceId,
);
}
};
xhttp.open("POST", serverUrl, true);
xhttp.send(new FormData(form));
function placeholder(inner: ReactNode) {
return <div className={`${classNames} bg-red-900`}>{inner}</div>;
}
if (!spec.active) {
return (
<div className="text-center text-xl uppercase bg-red-800 rounded-xl py-3 font-bold">
Unavailable
</div>
);
if (!spec.enabled) {
return placeholder("Unavailable");
}
if (!login) {
return placeholder("Please Login");
}
return (
<form
method="POST"
action={serverUrl}
className="btcpay-form btcpay-form--block w-full"
onSubmit={handleFormSubmit}
<AsyncButton
className={`${classNames} bg-green-800`}
onClick={() =>
navigte("/order", {
state: spec,
})
}
>
<input
type="hidden"
name="storeId"
value="CdaHy1puLx4kLC9BG3A9mu88XNyLJukMJRuuhAfbDrxg"
/>
<input type="hidden" name="jsonResponse" value="true" />
<input type="hidden" name="orderId" value={spec.id} />
<input type="hidden" name="price" value={spec.cost.count} />
<input type="hidden" name="currency" value={spec.cost.currency} />
<input
type="image"
className="w-full"
name="submit"
src="https://btcpay.v0l.io/img/paybutton/pay.svg"
alt="Pay with BTCPay Server, a Self-Hosted Bitcoin Payment Processor"
/>
</form>
Buy Now
</AsyncButton>
);
}

50
src/components/qr.tsx Normal file
View File

@ -0,0 +1,50 @@
import QRCodeStyling from "qr-code-styling";
import { useEffect, useRef } from "react";
export interface QrCodeProps {
data?: string;
link?: string;
avatar?: string;
height?: number;
width?: number;
className?: string;
}
export default function QrCode(props: QrCodeProps) {
const qrRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if ((props.data?.length ?? 0) > 0 && qrRef.current) {
const qr = new QRCodeStyling({
width: props.width || 256,
height: props.height || 256,
data: props.data,
margin: 5,
type: "canvas",
image: props.avatar,
dotsOptions: {
type: "rounded",
},
cornersSquareOptions: {
type: "extra-rounded",
},
imageOptions: {
crossOrigin: "anonymous",
},
});
qrRef.current.innerHTML = "";
qr.append(qrRef.current);
if (props.link) {
qrRef.current.onclick = function () {
const elm = document.createElement("a");
elm.href = props.link ?? "";
elm.click();
};
}
} else if (qrRef.current) {
qrRef.current.innerHTML = "";
}
}, [props.data, props.link, props.width, props.height, props.avatar]);
return <div className={props.className} ref={qrRef}></div>;
}

View File

@ -0,0 +1,28 @@
import { VmInstance } from "../api";
import { Icon } from "./icon";
export default function VmActions({ vm }: { vm: VmInstance }) {
const state = vm.status?.state;
if (!state) return;
return (
<div className="flex flex-col gap-1">
<div className="flex gap-2">
<Icon
name={state === "running" ? "stop" : "start"}
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
size={40}
/>
<Icon
name="delete"
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
size={40}
/>
<Icon
name="refresh-1"
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
size={40}
/>
</div>
</div>
);
}

View File

@ -1,28 +1,23 @@
import { DiskType, MachineSpec } from "../api";
import { VmTemplate } from "../api";
import BytesSize from "./bytes";
import CostLabel from "./cost";
import VpsPayButton from "./pay-button";
export default function VpsCard({ spec }: { spec: MachineSpec }) {
export default function VpsCard({ spec }: { spec: VmTemplate }) {
return (
<div className="rounded-xl border border-neutral-600 px-3 py-2">
<h2>{spec.id}</h2>
<h2>{spec.name}</h2>
<ul>
<li>CPU: {spec.cpu}vCPU</li>
<li>
RAM: <BytesSize value={spec.ram} />
RAM: <BytesSize value={spec.memory} />
</li>
<li>
{spec.disk.type === DiskType.SSD ? "SSD" : "HDD"}:{" "}
<BytesSize value={spec.disk.size} />
</li>
<li>
Location: {spec.location}
{spec.disk_type.toUpperCase()}: <BytesSize value={spec.disk_size} />
</li>
<li>Location: {spec.region?.name}</li>
</ul>
<h2>
<CostLabel cost={spec.cost} />
</h2>
<h2>{spec.cost_plan && <CostLabel cost={spec.cost_plan} />}</h2>
<VpsPayButton spec={spec} />
</div>
);

View File

@ -0,0 +1,37 @@
import { Link } from "react-router-dom";
import { VmInstance } from "../api";
import OsImageName from "./os-image-name";
import VpsResources from "./vps-resources";
import VmActions from "./vps-actions";
export default function VpsInstanceRow({ vm }: { vm: VmInstance }) {
const expires = new Date(vm.expires);
const isExpired = expires <= new Date();
return (
<div className="flex justify-between items-center rounded-xl bg-neutral-900 px-3 py-2 cursor-pointer hover:bg-neutral-800">
<div className="flex flex-col gap-2">
<div>
<span className="text-sm text-neutral-400">#{vm.id}</span>
&nbsp;
{vm.template?.name}
&nbsp;
<span className="text-sm text-neutral-400">
<OsImageName image={vm.image!} />
</span>
</div>
<VpsResources vm={vm} />
</div>
<div className="flex gap-2 items-center">
{isExpired && (
<>
<Link to="/vm/renew" className="text-red-500 text-sm" state={vm}>
Expired
</Link>
</>
)}
{!isExpired && <VmActions vm={vm} />}
</div>
</div>
);
}

View File

@ -0,0 +1,58 @@
import { useEffect } from "react";
import { LNVpsApi, VmPayment } from "../api";
import QrCode from "./qr";
import useLogin from "../hooks/login";
import { ApiUrl } from "../const";
import { EventPublisher } from "@snort/system";
export default function VpsPayment({
payment,
onPaid,
}: {
payment: VmPayment;
onPaid?: () => void;
}) {
const login = useLogin();
const ln = `lightning:${payment.invoice}`;
async function checkPayment(api: LNVpsApi) {
try {
const st = await api.paymentStatus(payment.id);
if (st.is_paid) {
onPaid?.();
return true;
}
} catch (e) {
console.error(e);
}
return false;
}
useEffect(() => {
if (!login?.signer) return;
const api = new LNVpsApi(
ApiUrl,
new EventPublisher(login.signer, login.pubkey),
);
const tx = setInterval(async () => {
if (await checkPayment(api)) {
clearInterval(tx);
}
}, 2_000);
return () => clearInterval(tx);
}, [login]);
return (
<div className="flex flex-col gap-4 rounded-xl p-3 bg-neutral-900 items-center">
<QrCode
data={ln}
link={ln}
width={512}
height={512}
avatar="/logo.jpg"
className="cursor-pointer rounded-xl overflow-hidden"
/>
{(payment.amount / 1000).toLocaleString()} sats
</div>
);
}

View File

@ -0,0 +1,31 @@
import { VmInstance, VmTemplate } from "../api";
import BytesSize from "./bytes";
export default function VpsResources({ vm }: { vm: VmInstance | VmTemplate }) {
const diskType = "template_id" in vm ? vm.template?.disk_type : vm.disk_type;
const region =
"region_id" in vm ? vm.region?.name : vm.template?.region?.name;
const status = "status" in vm ? vm.status : undefined;
return (
<>
<div className="text-xs text-neutral-400">
{vm.cpu} vCPU, <BytesSize value={vm.memory} /> RAM,{" "}
<BytesSize value={vm.disk_size} /> {diskType?.toUpperCase()},{" "}
{region && <>Location: {region}</>}
</div>
{status && status.state === "running" && (
<div className="text-sm text-neutral-200">
<div className="w-2 h-2 rounded-full bg-green-800 inline-block"></div>{" "}
{(100 * status.cpu_usage).toFixed(1)}% CPU,{" "}
{(100 * status.mem_usage).toFixed(0)}% RAM
</div>
)}
{status && status.state === "stopped" && (
<div className="text-sm text-neutral-200">
<div className="w-2 h-2 rounded-full bg-red-800 inline-block"></div>{" "}
Stopped
</div>
)}
</>
);
}