feat: start/stop vm

This commit is contained in:
kieran 2024-11-29 17:34:02 +00:00
parent fc1962defc
commit 658e4aa5f2
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
7 changed files with 148 additions and 19 deletions

View File

@ -122,6 +122,20 @@ export class LNVpsApi {
return data;
}
async startVm(id: number) {
const { data } = await this.#handleResponse<ApiResponse<VmInstance>>(
await this.#req(`/api/v1/vm/${id}/start`, "PATCH"),
);
return data;
}
async stopVm(id: number) {
const { data } = await this.#handleResponse<ApiResponse<VmInstance>>(
await this.#req(`/api/v1/vm/${id}/stop`, "PATCH"),
);
return data;
}
async listOffers() {
const { data } = await this.#handleResponse<ApiResponse<Array<VmTemplate>>>(
await this.#req("/api/v1/vm/templates", "GET"),
@ -192,7 +206,11 @@ export class LNVpsApi {
}
}
async #req(path: string, method: "GET" | "POST" | "DELETE", body?: object) {
async #req(
path: string,
method: "GET" | "POST" | "DELETE" | "PUT" | "PATCH",
body?: object,
) {
const auth = async (url: string, method: string) => {
const auth = await this.publisher?.generic((eb) => {
return eb

View File

@ -1,18 +1,28 @@
import classNames from "classnames";
import { forwardRef, HTMLProps } from "react";
import { forwardRef, HTMLProps, useState } from "react";
import Spinner from "./spinner";
export type AsyncButtonProps = {
onClick?: (e: React.MouseEvent) => Promise<void> | void;
} & Omit<HTMLProps<HTMLButtonElement>, "type" | "ref" | "onClick">;
const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>(
function AsyncButton({ className, ...props }, ref) {
function AsyncButton({ className, onClick, ...props }, ref) {
const [loading, setLoading] = useState(false);
const hasBg = className?.includes("bg-");
return (
<button
ref={ref}
onClick={async (e) => {
setLoading(true);
try {
await onClick?.(e);
} finally {
setLoading(false);
}
}}
className={classNames(
"py-1 px-2 rounded-xl font-medium",
"py-1 px-2 rounded-xl font-medium relative",
{
"bg-neutral-800 cursor-not-allowed text-neutral-500":
!hasBg && props.disabled === true,
@ -22,7 +32,17 @@ const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>(
)}
{...props}
>
{props.children}
<span
style={{ visibility: loading ? "hidden" : "visible" }}
className="whitespace-nowrap items-center justify-center"
>
{props.children}
</span>
{loading && (
<span className="absolute w-full h-full top-0 left-0 flex items-center justify-center">
<Spinner />
</span>
)}
</button>
);
},

View File

@ -0,0 +1,33 @@
.spinner_V8m1 {
transform-origin: center;
animation: spinner_zKoa 2s linear infinite;
}
.spinner_V8m1 circle {
stroke-linecap: round;
animation: spinner_YpZS 1.5s ease-in-out infinite;
}
@keyframes spinner_zKoa {
100% {
transform: rotate(360deg);
}
}
@keyframes spinner_YpZS {
0% {
stroke-dasharray: 0 150;
stroke-dashoffset: 0;
}
47.5% {
stroke-dasharray: 42 150;
stroke-dashoffset: -16;
}
95%,
100% {
stroke-dasharray: 42 150;
stroke-dashoffset: -59;
}
}

View File

@ -0,0 +1,23 @@
import "./spinner.css";
export interface IconProps {
className?: string;
width?: number;
height?: number;
}
const Spinner = (props: IconProps) => (
<svg
width="20"
height="20"
stroke="currentColor"
viewBox="0 0 20 20"
{...props}
>
<g className="spinner_V8m1">
<circle cx="10" cy="10" r="7.5" fill="none" strokeWidth="3"></circle>
</g>
</svg>
);
export default Spinner;

View File

@ -1,21 +1,45 @@
import { VmInstance } from "../api";
import { EventPublisher } from "@snort/system";
import { LNVpsApi, VmInstance } from "../api";
import { ApiUrl } from "../const";
import useLogin from "../hooks/login";
import { Icon } from "./icon";
import { AsyncButton } from "./button";
export default function VmActions({ vm }: { vm: VmInstance }) {
export default function VmActions({
vm,
onReload,
}: {
vm: VmInstance;
onReload?: () => void;
}) {
const login = useLogin();
const state = vm.status?.state;
if (!state) return;
const api = new LNVpsApi(
ApiUrl,
login?.signer ? new EventPublisher(login.signer, login.pubkey) : undefined,
);
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}
onClick={(e) => {
<AsyncButton
onClick={async (e) => {
e.stopPropagation();
if (state === "running") {
await api.stopVm(vm.id);
} else {
await api.startVm(vm.id);
}
onReload?.();
}}
/>
<Icon
className="bg-neutral-700 hover:bg-neutral-600"
>
<Icon name={state === "running" ? "stop" : "start"} size={30} />
</AsyncButton>
{/*<Icon
name="delete"
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
size={40}
@ -30,7 +54,7 @@ export default function VmActions({ vm }: { vm: VmInstance }) {
onClick={(e) => {
e.stopPropagation();
}}
/>
/>*/}
</div>
</div>
);

View File

@ -7,9 +7,11 @@ import VmActions from "./vps-actions";
export default function VpsInstanceRow({
vm,
actions,
onReload,
}: {
vm: VmInstance;
actions?: boolean;
onReload?: () => void;
}) {
const expires = new Date(vm.expires);
const isExpired = expires <= new Date();
@ -44,7 +46,9 @@ export default function VpsInstanceRow({
</Link>
</>
)}
{!isExpired && (actions ?? true) && <VmActions vm={vm} />}
{!isExpired && (actions ?? true) && (
<VmActions vm={vm} onReload={onReload} />
)}
</div>
</div>
);

View File

@ -9,13 +9,20 @@ export default function AccountPage() {
const login = useLogin();
const [vms, setVms] = useState<Array<VmInstance>>([]);
useEffect(() => {
async function loadVms() {
if (!login?.signer) return;
const api = new LNVpsApi(
ApiUrl,
new EventPublisher(login.signer, login.pubkey),
);
api.listVms().then(setVms);
const vms = await api.listVms();
setVms(vms);
}
useEffect(() => {
loadVms();
const t = setInterval(() => loadVms(), 5_000);
return () => clearInterval(t);
}, [login]);
return (
@ -23,7 +30,7 @@ export default function AccountPage() {
<h3>My Resources</h3>
<div className="flex flex-col gap-2">
{vms.map((a) => (
<VpsInstanceRow key={a.id} vm={a} actions={false} />
<VpsInstanceRow key={a.id} vm={a} onReload={loadVms} />
))}
</div>
</>