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",
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
}
"bg-neutral-900 outline-neutral-800 text-neutral-500": !active,
},
)}
onClick={onClick}>
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={{
return (
<div>
<CostAmount
cost={{
...converted_price,
interval_type: cost.interval_type
}} converted={true} />
<CostAmount cost={cost} converted={false} className="text-sm text-neutral-400" />
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}>

View File

@ -3,7 +3,9 @@ import { useEffect, useRef } from "react";
import { VmCostPlan } from "../api";
interface RevolutProps {
amount: VmCostPlan | {
amount:
| VmCostPlan
| {
amount: number;
currency: string;
tax?: number;

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)}>
{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>}
<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">
<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>
<div className="monospace select-all break-all text-center text-sm">
{invoice}

View File

@ -19,7 +19,8 @@ export default function useLogin() {
system,
currency: session.currency,
api: new LNVpsApi(ApiUrl, LoginState.getSigner()),
update: (fx: (ses: LoginSession) => void) => LoginState.updateSession(fx),
update: (fx: (ses: LoginSession) => void) =>
LoginState.updateSession(fx),
logout: () => LoginState.logout(),
}
: undefined,

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,19 +15,23 @@ 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}
<select
value={acc?.country_code}
onChange={(e) =>
setAcc((s) => (s ? { ...s, country_code: e.target.value } : undefined))
setAcc((s) =>
s ? { ...s, country_code: e.target.value } : undefined,
)
}
>
{iso.all().map(c => <option value={c.alpha3}>{c.country}</option>)}
{iso.all().map((c) => (
<option value={c.alpha3}>{c.country}</option>
))}
</select>
</div>
@ -83,4 +87,5 @@ export function AccountSettings() {
</AsyncButton>
</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) => {
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>);
},
{} 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:
<div className="flex gap-4 items-center">
{Object.keys(regions).length > 1 && (
<FilterSection header={"Region"}>
{Object.values(regions).map((r) => {
return <FilterButton
return (
<FilterButton
active={region.includes(r.id)}
onClick={() => setRegion(x => {
onClick={() =>
setRegion((x) => {
if (x.includes(r.id)) {
return x.filter(y => y != r.id);
return x.filter((y) => y != r.id);
} else {
return [...x, r.id];
return appendDedupe(x, [r.id]);
}
})}>
})
}
>
{r.name}
</FilterButton>;
</FilterButton>
);
})}
</div>}
</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 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">
</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>
)}
<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,7 +10,6 @@ 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>();
@ -32,7 +31,7 @@ export function VmConsolePage() {
if (t) {
t.dispose();
}
return te
return te;
});
}
@ -49,8 +48,10 @@ export function VmConsolePage() {
openTerminal();
}, []);
return <div className="flex flex-col gap-4">
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);