feat: start/stop vm
This commit is contained in:
parent
fc1962defc
commit
658e4aa5f2
20
src/api.ts
20
src/api.ts
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
33
src/components/spinner.css
Normal file
33
src/components/spinner.css
Normal 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;
|
||||
}
|
||||
}
|
23
src/components/spinner.tsx
Normal file
23
src/components/spinner.tsx
Normal 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;
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
</>
|
||||
|
Loading…
x
Reference in New Issue
Block a user