feat: filter disk type on templates
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-04-02 09:56:00 +01:00
parent db66cd4dc3
commit 5176386849
13 changed files with 375 additions and 262 deletions

View File

@ -1,15 +1,27 @@
import classNames from "classnames";
import { ReactNode } from "react";
export function FilterButton({ children, onClick, active }: { children: ReactNode, onClick?: () => Promise<void> | void, active?: boolean }) {
return <div
className={classNames("rounded-full outline outline-1 px-4 py-1 cursor-pointer select-none",
{
"bg-neutral-800 outline-neutral-300": active,
"bg-neutral-900 outline-neutral-800 text-neutral-500": !active
}
)}
onClick={onClick}>
{children}
export function FilterButton({
children,
onClick,
active,
}: {
children: ReactNode;
onClick?: () => Promise<void> | void;
active?: boolean;
}) {
return (
<div
className={classNames(
"rounded-full outline outline-1 px-4 py-1 cursor-pointer select-none",
{
"bg-neutral-800 outline-neutral-300": active,
"bg-neutral-900 outline-neutral-800 text-neutral-500": !active,
},
)}
onClick={onClick}
>
{children}
</div>
}
);
}

View File

@ -6,23 +6,38 @@ interface Price {
}
type Cost = Price & { interval_type?: string };
export default function CostLabel({ cost }: { cost: Cost & { other_price?: Array<Price> } }) {
export default function CostLabel({
cost,
}: {
cost: Cost & { other_price?: Array<Price> };
}) {
const login = useLogin();
if (cost.currency === login?.currency) {
return <CostAmount cost={cost} converted={false} />
return <CostAmount cost={cost} converted={false} />;
} else {
const converted_price = cost.other_price?.find((p) => p.currency === login?.currency);
const converted_price = cost.other_price?.find(
(p) => p.currency === login?.currency,
);
if (converted_price) {
return <div>
<CostAmount cost={{
...converted_price,
interval_type: cost.interval_type
}} converted={true} />
<CostAmount cost={cost} converted={false} className="text-sm text-neutral-400" />
</div>
return (
<div>
<CostAmount
cost={{
...converted_price,
interval_type: cost.interval_type,
}}
converted={true}
/>
<CostAmount
cost={cost}
converted={false}
className="text-sm text-neutral-400"
/>
</div>
);
} else {
return <CostAmount cost={cost} converted={false} />
return <CostAmount cost={cost} converted={false} />;
}
}
}
@ -38,11 +53,19 @@ function intervalName(n: string) {
}
}
export function CostAmount({ cost, converted, className }: { cost: Cost, converted: boolean, className?: string }) {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
export function CostAmount({
cost,
converted,
className,
}: {
cost: Cost;
converted: boolean;
className?: string;
}) {
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: cost.currency,
trailingZeroDisplay: 'stripIfInteger'
trailingZeroDisplay: "stripIfInteger",
});
return (
<div className={className}>
@ -54,4 +77,4 @@ export function CostAmount({ cost, converted, className }: { cost: Cost, convert
{cost.interval_type && <>/{intervalName(cost.interval_type)}</>}
</div>
);
}
}

View File

@ -3,63 +3,65 @@ import { useEffect, useRef } from "react";
import { VmCostPlan } from "../api";
interface RevolutProps {
amount: VmCostPlan | {
amount:
| VmCostPlan
| {
amount: number;
currency: string;
tax?: number;
};
pubkey: string;
loadOrder: () => Promise<string>;
onPaid: () => void;
onCancel?: () => void;
mode?: string;
};
pubkey: string;
loadOrder: () => Promise<string>;
onPaid: () => void;
onCancel?: () => void;
mode?: string;
}
export function RevolutPayWidget({
pubkey,
loadOrder,
amount,
onPaid,
onCancel,
mode,
pubkey,
loadOrder,
amount,
onPaid,
onCancel,
mode,
}: RevolutProps) {
const ref = useRef<HTMLDivElement | null>(null);
async function load(pubkey: string, ref: HTMLDivElement) {
const { revolutPay } = await RevolutCheckout.payments({
locale: "auto",
mode: (mode ?? "prod") as Mode,
publicToken: pubkey,
});
ref.innerHTML = "";
revolutPay.mount(ref, {
currency: amount.currency,
totalAmount: amount.amount,
createOrder: async () => {
const id = await loadOrder();
return {
publicId: id,
};
},
buttonStyle: {
cashback: false,
},
});
revolutPay.on("payment", (payload) => {
console.debug(payload);
if (payload.type === "success") {
onPaid();
}
if (payload.type === "cancel") {
onCancel?.();
}
});
const ref = useRef<HTMLDivElement | null>(null);
async function load(pubkey: string, ref: HTMLDivElement) {
const { revolutPay } = await RevolutCheckout.payments({
locale: "auto",
mode: (mode ?? "prod") as Mode,
publicToken: pubkey,
});
ref.innerHTML = "";
revolutPay.mount(ref, {
currency: amount.currency,
totalAmount: amount.amount,
createOrder: async () => {
const id = await loadOrder();
return {
publicId: id,
};
},
buttonStyle: {
cashback: false,
},
});
revolutPay.on("payment", (payload) => {
console.debug(payload);
if (payload.type === "success") {
onPaid();
}
if (payload.type === "cancel") {
onCancel?.();
}
});
}
useEffect(() => {
if (ref.current) {
load(pubkey, ref.current);
}
}, [pubkey, ref]);
useEffect(() => {
if (ref.current) {
load(pubkey, ref.current);
}
}, [pubkey, ref]);
return <div ref={ref}></div>;
return <div ref={ref}></div>;
}

View File

@ -46,19 +46,19 @@ export default function VmActions({
title="Reinstall"
onClick={async (e) => {
e.stopPropagation();
if(confirm("Are you sure you want to re-install your vm?\nTHIS WILL DELETE ALL DATA!!")) {
if (
confirm(
"Are you sure you want to re-install your vm?\nTHIS WILL DELETE ALL DATA!!",
)
) {
await login?.api.reisntallVm(vm.id);
onReload?.();
}
}}
className="bg-neutral-700 hover:bg-neutral-600"
>
<Icon
name="refresh-1"
size={30}
/>
<Icon name="refresh-1" size={30} />
</AsyncButton>
</div>
</div>
);

View File

@ -8,9 +8,7 @@ export default function VpsCard({ spec }: { spec: VmTemplate }) {
<div className="rounded-xl border border-neutral-600 px-3 py-2 flex flex-col gap-2">
<div className="text-xl">{spec.name}</div>
<ul>
<li>
CPU: {spec.cpu}vCPU
</li>
<li>CPU: {spec.cpu}vCPU</li>
<li>
RAM: <BytesSize value={spec.memory} />
</li>
@ -19,7 +17,9 @@ export default function VpsCard({ spec }: { spec: VmTemplate }) {
</li>
<li>Location: {spec.region?.name}</li>
</ul>
<div className="text-lg">{spec.cost_plan && <CostLabel cost={spec.cost_plan} />}</div>
<div className="text-lg">
{spec.cost_plan && <CostLabel cost={spec.cost_plan} />}
</div>
<VpsPayButton spec={spec} />
</div>
);

View File

@ -21,8 +21,9 @@ export function VpsCustomOrder({
const [cpu, setCpu] = useState(params.min_cpu ?? 1);
const [diskType, setDiskType] = useState(params.disks.at(0));
const [ram, setRam] = useState(Math.floor((params.min_memory ?? GiB) / GiB));
const [disk, setDisk] = useState(Math.floor((diskType?.min_disk ?? GiB) / GiB));
const [disk, setDisk] = useState(
Math.floor((diskType?.min_disk ?? GiB) / GiB),
);
const [price, setPrice] = useState<VmCustomPrice>();
@ -57,14 +58,18 @@ export function VpsCustomOrder({
return (
<div className="flex flex-col gap-4 bg-neutral-900 rounded-xl px-4 py-6">
<div className="text-lg">Custom VPS Order</div>
{params.disks.length > 1 && <div className="flex gap-2">
{params.disks.map((d) =>
<FilterButton active={diskType?.disk_type === d.disk_type}
onClick={() => setDiskType(d)}>
{d.disk_type.toUpperCase()}
</FilterButton>
)}
</div>}
{params.disks.length > 1 && (
<div className="flex gap-2">
{params.disks.map((d) => (
<FilterButton
active={diskType?.disk_type === d.disk_type}
onClick={() => setDiskType(d)}
>
{d.disk_type.toUpperCase()}
</FilterButton>
))}
</div>
)}
<div className="flex items-center gap-4">
<div className="min-w-[100px]">{cpu} CPU</div>
<input

View File

@ -49,10 +49,14 @@ export default function VpsPayment({
className="cursor-pointer rounded-xl overflow-hidden"
/>
<div className="flex flex-col items-center">
<div>{((payment.amount + payment.tax) / 1000).toLocaleString()} sats</div>
{payment.tax > 0 && <div className="text-xs">
including {(payment.tax / 1000).toLocaleString()} sats tax
</div>}
<div>
{((payment.amount + payment.tax) / 1000).toLocaleString()} sats
</div>
{payment.tax > 0 && (
<div className="text-xs">
including {(payment.tax / 1000).toLocaleString()} sats tax
</div>
)}
</div>
<div className="monospace select-all break-all text-center text-sm">
{invoice}

View File

@ -14,14 +14,15 @@ export default function useLogin() {
() =>
session
? {
type: session.type,
publicKey: session.publicKey,
system,
currency: session.currency,
api: new LNVpsApi(ApiUrl, LoginState.getSigner()),
update: (fx: (ses: LoginSession) => void) => LoginState.updateSession(fx),
logout: () => LoginState.logout(),
}
type: session.type,
publicKey: session.publicKey,
system,
currency: session.currency,
api: new LNVpsApi(ApiUrl, LoginState.getSigner()),
update: (fx: (ses: LoginSession) => void) =>
LoginState.updateSession(fx),
logout: () => LoginState.logout(),
}
: undefined,
[session, system],
);

View File

@ -43,7 +43,7 @@ class LoginStore extends ExternalStore<LoginSession | undefined> {
this.#session = {
type: type ?? "nip7",
publicKey: pubkey,
currency: "EUR"
currency: "EUR",
};
this.#save();
}
@ -54,7 +54,7 @@ class LoginStore extends ExternalStore<LoginSession | undefined> {
type: "nsec",
publicKey: s.getPubKey(),
privateKey: key,
currency: "EUR"
currency: "EUR",
};
this.#save();
}
@ -65,7 +65,7 @@ class LoginStore extends ExternalStore<LoginSession | undefined> {
publicKey: remotePubkey,
privateKey: localKey,
bunker: url,
currency: "EUR"
currency: "EUR",
};
this.#save();
}

View File

@ -15,72 +15,77 @@ export function AccountSettings() {
}, [login]);
if (!acc) return;
return <div className="flex flex-col gap-4">
<h3>
Account Settings
</h3>
return (
<div className="flex flex-col gap-4">
<h3>Account Settings</h3>
<div className="flex gap-2 items-center">
<h4>Country</h4>
<select value={acc?.country_code}
onChange={(e) =>
setAcc((s) => (s ? { ...s, country_code: e.target.value } : undefined))
}
>
{iso.all().map(c => <option value={c.alpha3}>{c.country}</option>)}
</select>
</div>
<h3>Notification Settings</h3>
<div className="flex gap-2 items-center">
<input
type="checkbox"
checked={acc?.contact_email ?? false}
onChange={(e) => {
setAcc((s) =>
s ? { ...s, contact_email: e.target.checked } : undefined,
);
}}
/>
Email
<input
type="checkbox"
checked={acc?.contact_nip17 ?? false}
onChange={(e) => {
setAcc((s) =>
s ? { ...s, contact_nip17: e.target.checked } : undefined,
);
}}
/>
Nostr DM
</div>
<div className="flex gap-2 items-center">
<h4>Email</h4>
<input
type="text"
disabled={!editEmail}
value={acc?.email}
onChange={(e) =>
setAcc((s) => (s ? { ...s, email: e.target.value } : undefined))
}
/>
{!editEmail && (
<Icon name="pencil" onClick={() => setEditEmail(true)} />
)}
</div>
<div>
<AsyncButton
onClick={async () => {
if (login?.api && acc) {
await login.api.updateAccount(acc);
const newAcc = await login.api.getAccount();
setAcc(newAcc);
setEditEmail(false);
<div className="flex gap-2 items-center">
<h4>Country</h4>
<select
value={acc?.country_code}
onChange={(e) =>
setAcc((s) =>
s ? { ...s, country_code: e.target.value } : undefined,
)
}
}}
>
Save
</AsyncButton>
>
{iso.all().map((c) => (
<option value={c.alpha3}>{c.country}</option>
))}
</select>
</div>
<h3>Notification Settings</h3>
<div className="flex gap-2 items-center">
<input
type="checkbox"
checked={acc?.contact_email ?? false}
onChange={(e) => {
setAcc((s) =>
s ? { ...s, contact_email: e.target.checked } : undefined,
);
}}
/>
Email
<input
type="checkbox"
checked={acc?.contact_nip17 ?? false}
onChange={(e) => {
setAcc((s) =>
s ? { ...s, contact_nip17: e.target.checked } : undefined,
);
}}
/>
Nostr DM
</div>
<div className="flex gap-2 items-center">
<h4>Email</h4>
<input
type="text"
disabled={!editEmail}
value={acc?.email}
onChange={(e) =>
setAcc((s) => (s ? { ...s, email: e.target.value } : undefined))
}
/>
{!editEmail && (
<Icon name="pencil" onClick={() => setEditEmail(true)} />
)}
</div>
<div>
<AsyncButton
onClick={async () => {
if (login?.api && acc) {
await login.api.updateAccount(acc);
const newAcc = await login.api.getAccount();
setAcc(newAcc);
setEditEmail(false);
}
}}
>
Save
</AsyncButton>
</div>
</div>
</div>
);
}

View File

@ -1,31 +1,37 @@
import { useState, useEffect } from "react";
import { LNVpsApi, VmHostRegion, VmTemplateResponse } from "../api";
import { useState, useEffect, ReactNode } from "react";
import { DiskType, LNVpsApi, VmHostRegion, VmTemplateResponse } from "../api";
import VpsCard from "../components/vps-card";
import { ApiUrl, NostrProfile } from "../const";
import { Link } from "react-router-dom";
import { VpsCustomOrder } from "../components/vps-custom";
import { LatestNews } from "../components/latest-news";
import { FilterButton } from "../components/button-filter";
import { dedupe } from "@snort/shared";
import { appendDedupe, dedupe } from "@snort/shared";
import useLogin from "../hooks/login";
export default function HomePage() {
const login = useLogin();
const [offers, setOffers] = useState<VmTemplateResponse>();
const [region, setRegion] = useState<Array<number>>([]);
const [diskType, setDiskType] = useState<Array<DiskType>>([]);
const regions = (offers?.templates.map((t) => t.region) ?? []).reduce((acc, v) => {
if (acc[v.id] === undefined) {
acc[v.id] = v;
}
return acc;
}, {} as Record<number, VmHostRegion>);
const regions = (offers?.templates.map((t) => t.region) ?? []).reduce(
(acc, v) => {
if (acc[v.id] === undefined) {
acc[v.id] = v;
}
return acc;
},
{} as Record<number, VmHostRegion>,
);
const diskTypes = dedupe(offers?.templates.map((t) => t.disk_type) ?? []);
useEffect(() => {
const api = new LNVpsApi(ApiUrl, undefined);
api.listOffers().then((o) => {
setOffers(o)
setOffers(o);
setRegion(dedupe(o.templates.map((z) => z.region.id)));
setDiskType(dedupe(o.templates.map((z) => z.disk_type)));
});
}, []);
@ -38,24 +44,58 @@ export default function HomePage() {
Virtual Private Server hosting with flexible plans, high uptime, and
dedicated support, tailored to your needs.
</div>
{Object.keys(regions).length > 1 && <div className="flex gap-2 items-center">
Regions:
{Object.values(regions).map((r) => {
return <FilterButton
active={region.includes(r.id)}
onClick={() => setRegion(x => {
if (x.includes(r.id)) {
return x.filter(y => y != r.id);
} else {
return [...x, r.id];
}
})}>
{r.name}
</FilterButton>;
})}
</div>}
<div className="flex gap-4 items-center">
{Object.keys(regions).length > 1 && (
<FilterSection header={"Region"}>
{Object.values(regions).map((r) => {
return (
<FilterButton
active={region.includes(r.id)}
onClick={() =>
setRegion((x) => {
if (x.includes(r.id)) {
return x.filter((y) => y != r.id);
} else {
return appendDedupe(x, [r.id]);
}
})
}
>
{r.name}
</FilterButton>
);
})}
</FilterSection>
)}
{diskTypes.length > 1 && (
<FilterSection header={"Disk"}>
{diskTypes.map((d) => (
<FilterButton
active={diskType.includes(d)}
onClick={() => {
setDiskType((s) => {
if (s?.includes(d)) {
return s.filter((y) => y !== d);
} else {
return appendDedupe(s, [d]);
}
});
}}
>
{d.toUpperCase()}
</FilterButton>
))}
</FilterSection>
)}
</div>
<div className="grid grid-cols-3 gap-2">
{offers?.templates.filter((t) => region.includes(t.region.id)).sort((a, b) => a.cost_plan.amount - b.cost_plan.amount).map((a) => <VpsCard spec={a} key={a.id} />)}
{offers?.templates
.filter(
(t) =>
region.includes(t.region.id) && diskType.includes(t.disk_type),
)
.sort((a, b) => a.cost_plan.amount - b.cost_plan.amount)
.map((a) => <VpsCard spec={a} key={a.id} />)}
{offers?.templates !== undefined && offers.templates.length === 0 && (
<div className="text-red-500 bold text-xl uppercase">
No offers available
@ -66,7 +106,8 @@ export default function HomePage() {
<VpsCustomOrder templates={offers.custom_template} />
)}
<small className="text-neutral-400 text-center">
All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic, all prices are excluding taxes.
All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic,
all prices are excluding taxes.
</small>
<div className="flex flex-col gap-4">
<div className="text-center">
@ -93,18 +134,29 @@ export default function HomePage() {
Speedtest
</a>
</div>
{import.meta.env.VITE_FOOTER_NOTE_1 && <div className="text-xs text-center text-neutral-400">
{import.meta.env.VITE_FOOTER_NOTE_1}
</div>}
{import.meta.env.VITE_FOOTER_NOTE_2 && <div className="text-xs text-center text-neutral-400">
{import.meta.env.VITE_FOOTER_NOTE_2}
</div>}
{import.meta.env.VITE_FOOTER_NOTE_1 && (
<div className="text-xs text-center text-neutral-400">
{import.meta.env.VITE_FOOTER_NOTE_1}
</div>
)}
{import.meta.env.VITE_FOOTER_NOTE_2 && (
<div className="text-xs text-center text-neutral-400">
{import.meta.env.VITE_FOOTER_NOTE_2}
</div>
)}
<div className="text-sm text-center">
Currency:
{" "}
<select value={login?.currency ?? "EUR"}
onChange={(e) => login?.update(s => s.currency = e.target.value)}>
{["BTC", "EUR", "USD", "GBP", "AUD", "CAD", "CHF", "JPY"].map((a) => <option>{a}</option>)}
Currency:{" "}
<select
value={login?.currency ?? "EUR"}
onChange={(e) =>
login?.update((s) => (s.currency = e.target.value))
}
>
{["BTC", "EUR", "USD", "GBP", "AUD", "CAD", "CHF", "JPY"].map(
(a) => (
<option>{a}</option>
),
)}
</select>
</div>
</div>
@ -112,3 +164,18 @@ export default function HomePage() {
</>
);
}
function FilterSection({
header,
children,
}: {
header?: ReactNode;
children?: ReactNode;
}) {
return (
<div className="flex flex-col gap-2 bg-neutral-900 px-3 py-2 rounded-xl">
<div className="text-md text-neutral-400">{header}</div>
<div className="flex gap-2 items-center">{children}</div>
</div>
);
}

View File

@ -10,47 +10,48 @@ import { AttachAddon } from "@xterm/addon-attach";
const fit = new FitAddon();
export function VmConsolePage() {
const { state } = useLocation() as { state?: VmInstance };
const login = useLogin();
const [term, setTerm] = useState<Terminal>();
const termRef = useRef<HTMLDivElement | null>(null);
const { state } = useLocation() as { state?: VmInstance };
const login = useLogin();
const [term, setTerm] = useState<Terminal>();
const termRef = useRef<HTMLDivElement | null>(null);
async function openTerminal() {
if (!login?.api || !state) return;
const ws = await login.api.connect_terminal(state.id);
const te = new Terminal();
const webgl = new WebglAddon();
webgl.onContextLoss(() => {
webgl.dispose();
});
te.loadAddon(webgl);
te.loadAddon(fit);
const attach = new AttachAddon(ws);
attach.activate(te);
setTerm((t) => {
if (t) {
t.dispose();
}
return te;
});
}
async function openTerminal() {
if (!login?.api || !state) return;
const ws = await login.api.connect_terminal(state.id);
const te = new Terminal();
const webgl = new WebglAddon();
webgl.onContextLoss(() => {
webgl.dispose();
});
te.loadAddon(webgl);
te.loadAddon(fit);
const attach = new AttachAddon(ws);
attach.activate(te);
setTerm((t) => {
if (t) {
t.dispose();
}
return te
});
useEffect(() => {
if (term && termRef.current) {
termRef.current.innerHTML = "";
term.open(termRef.current);
term.focus();
fit.fit();
}
}, [termRef, term]);
useEffect(() => {
if (term && termRef.current) {
termRef.current.innerHTML = "";
term.open(termRef.current);
term.focus();
fit.fit();
}
}, [termRef, term]);
useEffect(() => {
openTerminal();
}, []);
useEffect(() => {
openTerminal();
}, []);
return <div className="flex flex-col gap-4">
<div className="text-xl">VM #{state?.id} Terminal:</div>
{term && <div className="border p-2" ref={termRef}></div>}
return (
<div className="flex flex-col gap-4">
<div className="text-xl">VM #{state?.id} Terminal:</div>
{term && <div className="border p-2" ref={termRef}></div>}
</div>
}
);
}

View File

@ -10,8 +10,6 @@ import { Icon } from "../components/icon";
import Modal from "../components/modal";
import SSHKeySelector from "../components/ssh-keys";
export default function VmPage() {
const location = useLocation() as { state?: VmInstance };
const login = useLogin();
@ -66,14 +64,9 @@ export default function VmPage() {
if ((state.ip_assignments?.length ?? 0) === 0) {
return <div className="text-sm text-red-500">No IP's assigned</div>;
}
return (
<>
{state.ip_assignments?.map((i) => ipRow(i, true))}
</>
);
return <>{state.ip_assignments?.map((i) => ipRow(i, true))}</>;
}
useEffect(() => {
const t = setInterval(() => reloadVmState(), 5000);
return () => clearInterval(t);