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 classNames from "classnames";
import { ReactNode } from "react"; import { ReactNode } from "react";
export function FilterButton({ children, onClick, active }: { children: ReactNode, onClick?: () => Promise<void> | void, active?: boolean }) { export function FilterButton({
return <div children,
className={classNames("rounded-full outline outline-1 px-4 py-1 cursor-pointer select-none", 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-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} {children}
</div> </div>
);
} }

View File

@ -6,23 +6,38 @@ interface Price {
} }
type Cost = Price & { interval_type?: string }; 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(); const login = useLogin();
if (cost.currency === login?.currency) { if (cost.currency === login?.currency) {
return <CostAmount cost={cost} converted={false} /> return <CostAmount cost={cost} converted={false} />;
} else { } 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) { if (converted_price) {
return <div> return (
<CostAmount cost={{ <div>
<CostAmount
cost={{
...converted_price, ...converted_price,
interval_type: cost.interval_type interval_type: cost.interval_type,
}} converted={true} /> }}
<CostAmount cost={cost} converted={false} className="text-sm text-neutral-400" /> converted={true}
/>
<CostAmount
cost={cost}
converted={false}
className="text-sm text-neutral-400"
/>
</div> </div>
);
} else { } 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 }) { export function CostAmount({
const formatter = new Intl.NumberFormat('en-US', { cost,
style: 'currency', converted,
className,
}: {
cost: Cost;
converted: boolean;
className?: string;
}) {
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: cost.currency, currency: cost.currency,
trailingZeroDisplay: 'stripIfInteger' trailingZeroDisplay: "stripIfInteger",
}); });
return ( return (
<div className={className}> <div className={className}>

View File

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

View File

@ -46,19 +46,19 @@ export default function VmActions({
title="Reinstall" title="Reinstall"
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); 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); await login?.api.reisntallVm(vm.id);
onReload?.(); onReload?.();
} }
}} }}
className="bg-neutral-700 hover:bg-neutral-600" className="bg-neutral-700 hover:bg-neutral-600"
> >
<Icon <Icon name="refresh-1" size={30} />
name="refresh-1"
size={30}
/>
</AsyncButton> </AsyncButton>
</div> </div>
</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="rounded-xl border border-neutral-600 px-3 py-2 flex flex-col gap-2">
<div className="text-xl">{spec.name}</div> <div className="text-xl">{spec.name}</div>
<ul> <ul>
<li> <li>CPU: {spec.cpu}vCPU</li>
CPU: {spec.cpu}vCPU
</li>
<li> <li>
RAM: <BytesSize value={spec.memory} /> RAM: <BytesSize value={spec.memory} />
</li> </li>
@ -19,7 +17,9 @@ export default function VpsCard({ spec }: { spec: VmTemplate }) {
</li> </li>
<li>Location: {spec.region?.name}</li> <li>Location: {spec.region?.name}</li>
</ul> </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} /> <VpsPayButton spec={spec} />
</div> </div>
); );

View File

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

View File

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

View File

@ -19,7 +19,8 @@ export default function useLogin() {
system, system,
currency: session.currency, currency: session.currency,
api: new LNVpsApi(ApiUrl, LoginState.getSigner()), 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(), logout: () => LoginState.logout(),
} }
: undefined, : undefined,

View File

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

View File

@ -15,19 +15,23 @@ export function AccountSettings() {
}, [login]); }, [login]);
if (!acc) return; if (!acc) return;
return <div className="flex flex-col gap-4"> return (
<h3> <div className="flex flex-col gap-4">
Account Settings <h3>Account Settings</h3>
</h3>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<h4>Country</h4> <h4>Country</h4>
<select value={acc?.country_code} <select
value={acc?.country_code}
onChange={(e) => 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> </select>
</div> </div>
@ -83,4 +87,5 @@ export function AccountSettings() {
</AsyncButton> </AsyncButton>
</div> </div>
</div> </div>
);
} }

View File

@ -1,31 +1,37 @@
import { useState, useEffect } from "react"; import { useState, useEffect, ReactNode } from "react";
import { LNVpsApi, VmHostRegion, VmTemplateResponse } from "../api"; import { DiskType, LNVpsApi, VmHostRegion, VmTemplateResponse } from "../api";
import VpsCard from "../components/vps-card"; import VpsCard from "../components/vps-card";
import { ApiUrl, NostrProfile } from "../const"; import { ApiUrl, NostrProfile } from "../const";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { VpsCustomOrder } from "../components/vps-custom"; import { VpsCustomOrder } from "../components/vps-custom";
import { LatestNews } from "../components/latest-news"; import { LatestNews } from "../components/latest-news";
import { FilterButton } from "../components/button-filter"; import { FilterButton } from "../components/button-filter";
import { dedupe } from "@snort/shared"; import { appendDedupe, dedupe } from "@snort/shared";
import useLogin from "../hooks/login"; import useLogin from "../hooks/login";
export default function HomePage() { export default function HomePage() {
const login = useLogin(); const login = useLogin();
const [offers, setOffers] = useState<VmTemplateResponse>(); const [offers, setOffers] = useState<VmTemplateResponse>();
const [region, setRegion] = useState<Array<number>>([]); 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) { if (acc[v.id] === undefined) {
acc[v.id] = v; acc[v.id] = v;
} }
return acc; return acc;
}, {} as Record<number, VmHostRegion>); },
{} as Record<number, VmHostRegion>,
);
const diskTypes = dedupe(offers?.templates.map((t) => t.disk_type) ?? []);
useEffect(() => { useEffect(() => {
const api = new LNVpsApi(ApiUrl, undefined); const api = new LNVpsApi(ApiUrl, undefined);
api.listOffers().then((o) => { api.listOffers().then((o) => {
setOffers(o) setOffers(o);
setRegion(dedupe(o.templates.map((z) => z.region.id))); 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 Virtual Private Server hosting with flexible plans, high uptime, and
dedicated support, tailored to your needs. dedicated support, tailored to your needs.
</div> </div>
{Object.keys(regions).length > 1 && <div className="flex gap-2 items-center"> <div className="flex gap-4 items-center">
Regions: {Object.keys(regions).length > 1 && (
<FilterSection header={"Region"}>
{Object.values(regions).map((r) => { {Object.values(regions).map((r) => {
return <FilterButton return (
<FilterButton
active={region.includes(r.id)} active={region.includes(r.id)}
onClick={() => setRegion(x => { onClick={() =>
setRegion((x) => {
if (x.includes(r.id)) { if (x.includes(r.id)) {
return x.filter(y => y != r.id); return x.filter((y) => y != r.id);
} else { } else {
return [...x, r.id]; return appendDedupe(x, [r.id]);
} }
})}> })
}
>
{r.name} {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"> <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 && ( {offers?.templates !== undefined && offers.templates.length === 0 && (
<div className="text-red-500 bold text-xl uppercase"> <div className="text-red-500 bold text-xl uppercase">
No offers available No offers available
@ -66,7 +106,8 @@ export default function HomePage() {
<VpsCustomOrder templates={offers.custom_template} /> <VpsCustomOrder templates={offers.custom_template} />
)} )}
<small className="text-neutral-400 text-center"> <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> </small>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="text-center"> <div className="text-center">
@ -93,18 +134,29 @@ export default function HomePage() {
Speedtest Speedtest
</a> </a>
</div> </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} {import.meta.env.VITE_FOOTER_NOTE_1}
</div>} </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 className="text-xs text-center text-neutral-400">
{import.meta.env.VITE_FOOTER_NOTE_2} {import.meta.env.VITE_FOOTER_NOTE_2}
</div>} </div>
)}
<div className="text-sm text-center"> <div className="text-sm text-center">
Currency: Currency:{" "}
{" "} <select
<select value={login?.currency ?? "EUR"} value={login?.currency ?? "EUR"}
onChange={(e) => login?.update(s => s.currency = e.target.value)}> onChange={(e) =>
{["BTC", "EUR", "USD", "GBP", "AUD", "CAD", "CHF", "JPY"].map((a) => <option>{a}</option>)} login?.update((s) => (s.currency = e.target.value))
}
>
{["BTC", "EUR", "USD", "GBP", "AUD", "CAD", "CHF", "JPY"].map(
(a) => (
<option>{a}</option>
),
)}
</select> </select>
</div> </div>
</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(); const fit = new FitAddon();
export function VmConsolePage() { export function VmConsolePage() {
const { state } = useLocation() as { state?: VmInstance }; const { state } = useLocation() as { state?: VmInstance };
const login = useLogin(); const login = useLogin();
const [term, setTerm] = useState<Terminal>(); const [term, setTerm] = useState<Terminal>();
@ -32,7 +31,7 @@ export function VmConsolePage() {
if (t) { if (t) {
t.dispose(); t.dispose();
} }
return te return te;
}); });
} }
@ -49,8 +48,10 @@ export function VmConsolePage() {
openTerminal(); 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> <div className="text-xl">VM #{state?.id} Terminal:</div>
{term && <div className="border p-2" ref={termRef}></div>} {term && <div className="border p-2" ref={termRef}></div>}
</div> </div>
);
} }

View File

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