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,
"bg-neutral-800 outline-neutral-300": active, }: {
"bg-neutral-900 outline-neutral-800 text-neutral-500": !active children: ReactNode;
} onClick?: () => Promise<void> | void;
)} active?: boolean;
onClick={onClick}> }) {
{children} 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> </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>
...converted_price, <CostAmount
interval_type: cost.interval_type cost={{
}} converted={true} /> ...converted_price,
<CostAmount cost={cost} converted={false} className="text-sm text-neutral-400" /> interval_type: cost.interval_type,
</div> }}
converted={true}
/>
<CostAmount
cost={cost}
converted={false}
className="text-sm text-neutral-400"
/>
</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}>
@ -54,4 +77,4 @@ export function CostAmount({ cost, converted, className }: { cost: Cost, convert
{cost.interval_type && <>/{intervalName(cost.interval_type)}</>} {cost.interval_type && <>/{intervalName(cost.interval_type)}</>}
</div> </div>
); );
} }

View File

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

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
{d.disk_type.toUpperCase()} active={diskType?.disk_type === d.disk_type}
</FilterButton> onClick={() => setDiskType(d)}
)} >
</div>} {d.disk_type.toUpperCase()}
</FilterButton>
))}
</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
including {(payment.tax / 1000).toLocaleString()} sats tax </div>
</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"> <div className="monospace select-all break-all text-center text-sm">
{invoice} {invoice}

View File

@ -14,14 +14,15 @@ export default function useLogin() {
() => () =>
session session
? { ? {
type: session.type, type: session.type,
publicKey: session.publicKey, publicKey: session.publicKey,
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) =>
logout: () => LoginState.logout(), LoginState.updateSession(fx),
} logout: () => LoginState.logout(),
}
: undefined, : undefined,
[session, system], [session, system],
); );

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,72 +15,77 @@ 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
onChange={(e) => value={acc?.country_code}
setAcc((s) => (s ? { ...s, country_code: e.target.value } : undefined)) 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);
} }
}} >
> {iso.all().map((c) => (
Save <option value={c.alpha3}>{c.country}</option>
</AsyncButton> ))}
</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>
</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(
if (acc[v.id] === undefined) { (acc, v) => {
acc[v.id] = v; if (acc[v.id] === undefined) {
} acc[v.id] = v;
return acc; }
}, {} as Record<number, VmHostRegion>); return acc;
},
{} 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 && (
{Object.values(regions).map((r) => { <FilterSection header={"Region"}>
return <FilterButton {Object.values(regions).map((r) => {
active={region.includes(r.id)} return (
onClick={() => setRegion(x => { <FilterButton
if (x.includes(r.id)) { active={region.includes(r.id)}
return x.filter(y => y != r.id); onClick={() =>
} else { setRegion((x) => {
return [...x, r.id]; if (x.includes(r.id)) {
} return x.filter((y) => y != r.id);
})}> } else {
{r.name} return appendDedupe(x, [r.id]);
</FilterButton>; }
})} })
</div>} }
>
{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"> <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 && (
{import.meta.env.VITE_FOOTER_NOTE_1} <div className="text-xs text-center text-neutral-400">
</div>} {import.meta.env.VITE_FOOTER_NOTE_1}
{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>} {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"> <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,47 +10,48 @@ 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 login = useLogin();
const [term, setTerm] = useState<Terminal>();
const termRef = useRef<HTMLDivElement | null>(null);
const { state } = useLocation() as { state?: VmInstance }; async function openTerminal() {
const login = useLogin(); if (!login?.api || !state) return;
const [term, setTerm] = useState<Terminal>(); const ws = await login.api.connect_terminal(state.id);
const termRef = useRef<HTMLDivElement | null>(null); 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() { useEffect(() => {
if (!login?.api || !state) return; if (term && termRef.current) {
const ws = await login.api.connect_terminal(state.id); termRef.current.innerHTML = "";
const te = new Terminal(); term.open(termRef.current);
const webgl = new WebglAddon(); term.focus();
webgl.onContextLoss(() => { fit.fit();
webgl.dispose();
});
te.loadAddon(webgl);
te.loadAddon(fit);
const attach = new AttachAddon(ws);
attach.activate(te);
setTerm((t) => {
if (t) {
t.dispose();
}
return te
});
} }
}, [termRef, term]);
useEffect(() => { useEffect(() => {
if (term && termRef.current) { openTerminal();
termRef.current.innerHTML = ""; }, []);
term.open(termRef.current);
term.focus();
fit.fit();
}
}, [termRef, term]);
useEffect(() => { return (
openTerminal(); <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> </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);