Compare commits
10 Commits
1f5506a920
...
9f364d2854
Author | SHA1 | Date | |
---|---|---|---|
9f364d2854 | |||
e9d88279cf | |||
f4227fa121 | |||
1510b16ceb | |||
fead7f1bff | |||
658e4aa5f2 | |||
fc1962defc | |||
12c3ddc31d | |||
e4f6863e21 | |||
8ee0748740 |
1
.env.development
Normal file
1
.env.development
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL="http://localhost:8000"
|
@ -6,3 +6,4 @@ RUN yarn && yarn build
|
|||||||
FROM nginx as runner
|
FROM nginx as runner
|
||||||
WORKDIR /usr/share/nginx/html
|
WORKDIR /usr/share/nginx/html
|
||||||
COPY --from=builder /src/dist .
|
COPY --from=builder /src/dist .
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
10
nginx.conf
Normal file
10
nginx.conf
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html =404;
|
||||||
|
}
|
||||||
|
}
|
@ -12,8 +12,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scure/base": "^1.2.1",
|
"@scure/base": "^1.2.1",
|
||||||
"@snort/shared": "^1.0.17",
|
"@snort/shared": "^1.0.17",
|
||||||
"@snort/system": "^1.5.7",
|
"@snort/system": "^1.6.1",
|
||||||
"@snort/system-react": "^1.5.7",
|
"@snort/system-react": "^1.6.1",
|
||||||
|
"@xterm/addon-attach": "^0.11.0",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"qr-code-styling": "^1.8.4",
|
"qr-code-styling": "^1.8.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
@ -16,5 +16,10 @@
|
|||||||
<path d="M24 0V24H0V0H24Z" fill="white" fill-opacity="0.01"/>
|
<path d="M24 0V24H0V0H24Z" fill="white" fill-opacity="0.01"/>
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0589 19.2443C12.8824 20.0009 15.764 19.0421 17.5934 16.9994C18.146 16.3822 19.0943 16.33 19.7115 16.8826C20.3286 17.4353 20.3808 18.3836 19.8282 19.0008C17.2737 21.8532 13.2404 23.2026 9.28249 22.1421C3.6811 20.6412 0.35698 14.8837 1.85787 9.2823C3.35876 3.68091 9.1163 0.356795 14.7177 1.85768C18.9224 2.98433 21.8407 6.50832 22.4032 10.5596C22.4653 11.0066 22.4987 11.4603 22.502 11.9179C22.5117 13.2319 21.0529 13.9572 20.01 13.2545L17.3364 11.4531C15.8701 10.4651 16.8533 8.17943 18.579 8.56459L18.6789 8.58688C17.7458 6.76269 16.0738 5.32688 13.9412 4.75546C9.94024 3.6834 5.82771 6.05777 4.75565 10.0588C3.68358 14.0598 6.05795 18.1723 10.0589 19.2443Z" fill="#F7F9FC"/>
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0589 19.2443C12.8824 20.0009 15.764 19.0421 17.5934 16.9994C18.146 16.3822 19.0943 16.33 19.7115 16.8826C20.3286 17.4353 20.3808 18.3836 19.8282 19.0008C17.2737 21.8532 13.2404 23.2026 9.28249 22.1421C3.6811 20.6412 0.35698 14.8837 1.85787 9.2823C3.35876 3.68091 9.1163 0.356795 14.7177 1.85768C18.9224 2.98433 21.8407 6.50832 22.4032 10.5596C22.4653 11.0066 22.4987 11.4603 22.502 11.9179C22.5117 13.2319 21.0529 13.9572 20.01 13.2545L17.3364 11.4531C15.8701 10.4651 16.8533 8.17943 18.579 8.56459L18.6789 8.58688C17.7458 6.76269 16.0738 5.32688 13.9412 4.75546C9.94024 3.6834 5.82771 6.05777 4.75565 10.0588C3.68358 14.0598 6.05795 18.1723 10.0589 19.2443Z" fill="#F7F9FC"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<svg id="pencil" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M24 0V24H0V0H24Z" fill="white" fill-opacity="0.01"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1315 3.16087C18.9599 1.9893 17.0604 1.9893 15.8888 3.16087L15.1817 3.86798L20.1315 8.81773L20.8386 8.11062C22.0101 6.93905 22.0101 5.03955 20.8386 3.86798L20.1315 3.16087ZM18.7172 10.2319L13.7675 5.28219L4.6765 14.3732C4.47771 14.572 4.33879 14.8226 4.27557 15.0966L3.24752 19.5515C3.08116 20.2723 3.72726 20.9182 4.44797 20.7519L8.90288 19.7239C9.17681 19.6606 9.42746 19.5217 9.62625 19.3229L18.7172 10.2319Z" fill="#F7F9FC"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.5 KiB |
64
src/api.ts
64
src/api.ts
@ -73,6 +73,7 @@ export interface VmInstance {
|
|||||||
disk_size: number;
|
disk_size: number;
|
||||||
disk_id: number;
|
disk_id: number;
|
||||||
status?: VmStatus;
|
status?: VmStatus;
|
||||||
|
mac_address: string;
|
||||||
|
|
||||||
template?: VmTemplate;
|
template?: VmTemplate;
|
||||||
image?: VmOsImage;
|
image?: VmOsImage;
|
||||||
@ -102,11 +103,15 @@ export interface VmPayment {
|
|||||||
is_paid: boolean;
|
is_paid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PathVm {
|
||||||
|
ssh_key_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class LNVpsApi {
|
export class LNVpsApi {
|
||||||
constructor(
|
constructor(
|
||||||
readonly url: string,
|
readonly url: string,
|
||||||
readonly publisher: EventPublisher | undefined,
|
readonly publisher: EventPublisher | undefined,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
async listVms() {
|
async listVms() {
|
||||||
const { data } = await this.#handleResponse<ApiResponse<Array<VmInstance>>>(
|
const { data } = await this.#handleResponse<ApiResponse<Array<VmInstance>>>(
|
||||||
@ -122,6 +127,27 @@ export class LNVpsApi {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async patchVm(id: number, req: PathVm) {
|
||||||
|
const { data } = await this.#handleResponse<ApiResponse<void>>(
|
||||||
|
await this.#req(`/api/v1/vm/${id}`, "PATCH", req),
|
||||||
|
);
|
||||||
|
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() {
|
async listOffers() {
|
||||||
const { data } = await this.#handleResponse<ApiResponse<Array<VmTemplate>>>(
|
const { data } = await this.#handleResponse<ApiResponse<Array<VmTemplate>>>(
|
||||||
await this.#req("/api/v1/vm/templates", "GET"),
|
await this.#req("/api/v1/vm/templates", "GET"),
|
||||||
@ -178,6 +204,24 @@ export class LNVpsApi {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async connect_terminal(id: number) {
|
||||||
|
const u = `${this.url}/api/v1/console/${id}`;
|
||||||
|
const auth = await this.#auth_event(u, "GET");
|
||||||
|
const ws = new WebSocket(
|
||||||
|
`${u}?auth=${base64.encode(
|
||||||
|
new TextEncoder().encode(JSON.stringify(auth)),
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
return await new Promise<WebSocket>((resolve, reject) => {
|
||||||
|
ws.onopen = () => {
|
||||||
|
resolve(ws);
|
||||||
|
};
|
||||||
|
ws.onerror = (e) => {
|
||||||
|
reject(e);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async #handleResponse<T extends ApiResponseBase>(rsp: Response) {
|
async #handleResponse<T extends ApiResponseBase>(rsp: Response) {
|
||||||
if (rsp.ok) {
|
if (rsp.ok) {
|
||||||
return (await rsp.json()) as T;
|
return (await rsp.json()) as T;
|
||||||
@ -192,21 +236,29 @@ export class LNVpsApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async #req(path: string, method: "GET" | "POST" | "DELETE", body?: object) {
|
async #auth_event(url: string, method: string) {
|
||||||
const auth = async (url: string, method: string) => {
|
return await this.publisher?.generic((eb) => {
|
||||||
const auth = await this.publisher?.generic((eb) => {
|
|
||||||
return eb
|
return eb
|
||||||
.kind(EventKind.HttpAuthentication)
|
.kind(EventKind.HttpAuthentication)
|
||||||
.tag(["u", url])
|
.tag(["u", url])
|
||||||
.tag(["method", method]);
|
.tag(["method", method]);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async #auth(url: string, method: string) {
|
||||||
|
const auth = await this.#auth_event(url, method);
|
||||||
if (auth) {
|
if (auth) {
|
||||||
return `Nostr ${base64.encode(
|
return `Nostr ${base64.encode(
|
||||||
new TextEncoder().encode(JSON.stringify(auth)),
|
new TextEncoder().encode(JSON.stringify(auth)),
|
||||||
)}`;
|
)}`;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
async #req(
|
||||||
|
path: string,
|
||||||
|
method: "GET" | "POST" | "DELETE" | "PUT" | "PATCH",
|
||||||
|
body?: object,
|
||||||
|
) {
|
||||||
const u = `${this.url}${path}`;
|
const u = `${this.url}${path}`;
|
||||||
return await fetch(u, {
|
return await fetch(u, {
|
||||||
method,
|
method,
|
||||||
@ -214,7 +266,7 @@ export class LNVpsApi {
|
|||||||
headers: {
|
headers: {
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
authorization: (await auth(u, method)) ?? "",
|
authorization: (await this.#auth(u, method)) ?? "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
71
src/blossom.ts
Normal file
71
src/blossom.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { base64, bytesToString } from "@scure/base";
|
||||||
|
import { throwIfOffline, unixNow } from "@snort/shared";
|
||||||
|
import { EventKind, EventPublisher } from "@snort/system";
|
||||||
|
|
||||||
|
export interface BlobDescriptor {
|
||||||
|
url?: string;
|
||||||
|
sha256: string;
|
||||||
|
size: number;
|
||||||
|
type?: string;
|
||||||
|
uploaded?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Blossom {
|
||||||
|
constructor(
|
||||||
|
readonly url: string,
|
||||||
|
readonly publisher: EventPublisher,
|
||||||
|
) {
|
||||||
|
this.url = new URL(this.url).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(file: File) {
|
||||||
|
const hash = await window.crypto.subtle.digest(
|
||||||
|
"SHA-256",
|
||||||
|
await file.arrayBuffer(),
|
||||||
|
);
|
||||||
|
const tags = [["x", bytesToString("hex", new Uint8Array(hash))]];
|
||||||
|
|
||||||
|
const rsp = await this.#req("/upload", "PUT", file, tags);
|
||||||
|
if (rsp.ok) {
|
||||||
|
return (await rsp.json()) as BlobDescriptor;
|
||||||
|
} else {
|
||||||
|
const text = await rsp.text();
|
||||||
|
throw new Error(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #req(
|
||||||
|
path: string,
|
||||||
|
method: "GET" | "POST" | "DELETE" | "PUT",
|
||||||
|
body?: BodyInit,
|
||||||
|
tags?: Array<Array<string>>,
|
||||||
|
) {
|
||||||
|
throwIfOffline();
|
||||||
|
|
||||||
|
const url = `${this.url}upload`;
|
||||||
|
const now = unixNow();
|
||||||
|
const auth = async (url: string, method: string) => {
|
||||||
|
const auth = await this.publisher.generic((eb) => {
|
||||||
|
eb.kind(24_242 as EventKind)
|
||||||
|
.tag(["u", url])
|
||||||
|
.tag(["method", method])
|
||||||
|
.tag(["t", path.slice(1)])
|
||||||
|
.tag(["expiration", (now + 10).toString()]);
|
||||||
|
tags?.forEach((t) => eb.tag(t));
|
||||||
|
return eb;
|
||||||
|
});
|
||||||
|
return `Nostr ${base64.encode(
|
||||||
|
new TextEncoder().encode(JSON.stringify(auth)),
|
||||||
|
)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return await fetch(url, {
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
authorization: await auth(url, method),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,18 +1,28 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { forwardRef, HTMLProps } from "react";
|
import { forwardRef, HTMLProps, useState } from "react";
|
||||||
|
import Spinner from "./spinner";
|
||||||
|
|
||||||
export type AsyncButtonProps = {
|
export type AsyncButtonProps = {
|
||||||
onClick?: (e: React.MouseEvent) => Promise<void> | void;
|
onClick?: (e: React.MouseEvent) => Promise<void> | void;
|
||||||
} & Omit<HTMLProps<HTMLButtonElement>, "type" | "ref" | "onClick">;
|
} & Omit<HTMLProps<HTMLButtonElement>, "type" | "ref" | "onClick">;
|
||||||
|
|
||||||
const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>(
|
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-");
|
const hasBg = className?.includes("bg-");
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
onClick={async (e) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onClick?.(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={classNames(
|
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":
|
"bg-neutral-800 cursor-not-allowed text-neutral-500":
|
||||||
!hasBg && props.disabled === true,
|
!hasBg && props.disabled === true,
|
||||||
@ -21,8 +31,18 @@ const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>(
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{ visibility: loading ? "hidden" : "visible" }}
|
||||||
|
className="whitespace-nowrap items-center justify-center"
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
</span>
|
||||||
|
{loading && (
|
||||||
|
<span className="absolute w-full h-full top-0 left-0 flex items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
import { MouseEventHandler } from "react";
|
import { MouseEventHandler } from "react";
|
||||||
|
|
||||||
import Icons from "../icons.svg?url";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
name: string;
|
name: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
@ -11,13 +10,13 @@ type Props = {
|
|||||||
|
|
||||||
export function Icon(props: Props) {
|
export function Icon(props: Props) {
|
||||||
const size = props.size || 20;
|
const size = props.size || 20;
|
||||||
const href = `${Icons}#${props.name}`;
|
const href = `/icons.svg#${props.name}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
className={props.className}
|
className={classNames(props.className, "cursor-pointer")}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
>
|
>
|
||||||
<use href={href} />
|
<use href={href} />
|
||||||
|
@ -1,27 +1,24 @@
|
|||||||
import { SnortContext } from "@snort/system-react";
|
|
||||||
import { useContext } from "react";
|
|
||||||
import { AsyncButton } from "./button";
|
import { AsyncButton } from "./button";
|
||||||
import { loginNip7 } from "../login";
|
|
||||||
import useLogin from "../hooks/login";
|
import useLogin from "../hooks/login";
|
||||||
import Profile from "./profile";
|
import Profile from "./profile";
|
||||||
import { NostrLink } from "@snort/system";
|
import { NostrLink } from "@snort/system";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
export default function LoginButton() {
|
export default function LoginButton() {
|
||||||
const system = useContext(SnortContext);
|
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return !login ? (
|
return !login ? (
|
||||||
<AsyncButton
|
<AsyncButton
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await loginNip7(system);
|
navigate("/login");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</AsyncButton>
|
</AsyncButton>
|
||||||
) : (
|
) : (
|
||||||
<Link to="/account">
|
<Link to="/account">
|
||||||
<Profile link={NostrLink.publicKey(login.pubkey)} />
|
<Profile link={NostrLink.publicKey(login.publicKey)} />
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -51,7 +51,7 @@ export default function Modal(props: ModalProps) {
|
|||||||
className={
|
className={
|
||||||
props.bodyClassName ??
|
props.bodyClassName ??
|
||||||
classNames(
|
classNames(
|
||||||
"relative bg-layer-1 p-8 transition max-xl:rounded-t-3xl xl:rounded-3xl max-xl:mt-auto xl:my-auto max-lg:w-full",
|
"relative bg-neutral-700 p-8 transition max-xl:rounded-t-3xl xl:rounded-3xl max-xl:mt-auto xl:my-auto max-lg:w-full",
|
||||||
{
|
{
|
||||||
"max-xl:-translate-y-[calc(100vh-100dvh)]": props.ready ?? true,
|
"max-xl:-translate-y-[calc(100vh-100dvh)]": props.ready ?? true,
|
||||||
"max-xl:translate-y-[50vh]": !(props.ready ?? true),
|
"max-xl:translate-y-[50vh]": !(props.ready ?? true),
|
||||||
|
@ -4,6 +4,7 @@ import { useUserProfile } from "@snort/system-react";
|
|||||||
|
|
||||||
export default function Profile({ link }: { link: NostrLink }) {
|
export default function Profile({ link }: { link: NostrLink }) {
|
||||||
const profile = useUserProfile(link.id);
|
const profile = useUserProfile(link.id);
|
||||||
|
const name = profile?.display_name ?? profile?.name ?? "";
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<img
|
<img
|
||||||
@ -11,9 +12,7 @@ export default function Profile({ link }: { link: NostrLink }) {
|
|||||||
className="w-12 h-12 rounded-full bg-neutral-800 object-cover object-center"
|
className="w-12 h-12 rounded-full bg-neutral-800 object-cover object-center"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
{profile?.display_name ??
|
{name.length > 0 ? name : hexToBech32("npub", link.id).slice(0, 12)}
|
||||||
profile?.name ??
|
|
||||||
hexToBech32("npub", link.id).slice(0, 12)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
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;
|
97
src/components/ssh-keys.tsx
Normal file
97
src/components/ssh-keys.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { UserSshKey } from "../api";
|
||||||
|
import useLogin from "../hooks/login";
|
||||||
|
import { AsyncButton } from "./button";
|
||||||
|
|
||||||
|
export default function SSHKeySelector({
|
||||||
|
selectedKey,
|
||||||
|
setSelectedKey,
|
||||||
|
}: {
|
||||||
|
selectedKey: UserSshKey["id"];
|
||||||
|
setSelectedKey: (k: UserSshKey["id"]) => void;
|
||||||
|
}) {
|
||||||
|
const login = useLogin();
|
||||||
|
const [newKey, setNewKey] = useState("");
|
||||||
|
const [newKeyError, setNewKeyError] = useState("");
|
||||||
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
|
const [showAddKey, setShowAddKey] = useState(false);
|
||||||
|
const [sshKeys, setSshKeys] = useState<Array<UserSshKey>>([]);
|
||||||
|
|
||||||
|
async function addNewKey() {
|
||||||
|
if (!login?.api) return;
|
||||||
|
setNewKeyError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nk = await login?.api.addSshKey(newKeyName, newKey);
|
||||||
|
setNewKey("");
|
||||||
|
setNewKeyName("");
|
||||||
|
setSelectedKey(nk.id);
|
||||||
|
setShowAddKey(false);
|
||||||
|
login?.api.listSshKeys().then((a) => setSshKeys(a));
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
setNewKeyError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!login?.api) return;
|
||||||
|
login?.api.listSshKeys().then((a) => {
|
||||||
|
setSshKeys(a);
|
||||||
|
if (a.length > 0) {
|
||||||
|
setSelectedKey(a[0].id);
|
||||||
|
} else {
|
||||||
|
setShowAddKey(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{sshKeys.length > 0 && (
|
||||||
|
<>
|
||||||
|
<b>Select SSH Key:</b>
|
||||||
|
<select
|
||||||
|
className="bg-neutral-900 p-2 rounded-xl"
|
||||||
|
value={selectedKey}
|
||||||
|
onChange={(e) => setSelectedKey(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{sshKeys.map((a) => (
|
||||||
|
<option value={a.id}>{a.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!showAddKey && sshKeys.length > 0 && (
|
||||||
|
<AsyncButton onClick={() => setShowAddKey(true)}>
|
||||||
|
Add new SSH key
|
||||||
|
</AsyncButton>
|
||||||
|
)}
|
||||||
|
{(showAddKey || sshKeys.length === 0) && (
|
||||||
|
<>
|
||||||
|
<b>Add SSH Key:</b>
|
||||||
|
<textarea
|
||||||
|
rows={5}
|
||||||
|
placeholder="ssh-[rsa|ed25519] AA== id"
|
||||||
|
value={newKey}
|
||||||
|
onChange={(e) => setNewKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Key name"
|
||||||
|
value={newKeyName}
|
||||||
|
onChange={(e) => setNewKeyName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<AsyncButton
|
||||||
|
disabled={newKey.length < 10 || newKeyName.length < 2}
|
||||||
|
onClick={addNewKey}
|
||||||
|
>
|
||||||
|
Add Key
|
||||||
|
</AsyncButton>
|
||||||
|
{newKeyError && <b className="text-red-500">{newKeyError}</b>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,25 +1,43 @@
|
|||||||
import { VmInstance } from "../api";
|
import { VmInstance } from "../api";
|
||||||
|
import useLogin from "../hooks/login";
|
||||||
import { Icon } from "./icon";
|
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;
|
const state = vm.status?.state;
|
||||||
if (!state) return;
|
if (!login?.api) return;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Icon
|
<AsyncButton
|
||||||
name={state === "running" ? "stop" : "start"}
|
onClick={async (e) => {
|
||||||
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
|
|
||||||
size={40}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (state === "running") {
|
||||||
|
await login?.api.stopVm(vm.id);
|
||||||
|
} else {
|
||||||
|
await login?.api.startVm(vm.id);
|
||||||
|
}
|
||||||
|
onReload?.();
|
||||||
}}
|
}}
|
||||||
/>
|
className="bg-neutral-700 hover:bg-neutral-600"
|
||||||
<Icon
|
>
|
||||||
|
<Icon name={state === "running" ? "stop" : "start"} size={30} />
|
||||||
|
</AsyncButton>
|
||||||
|
|
||||||
|
{/*<Icon
|
||||||
name="delete"
|
name="delete"
|
||||||
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
|
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
|
||||||
size={40}
|
size={40}
|
||||||
onClick={e => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -27,10 +45,10 @@ export default function VmActions({ vm }: { vm: VmInstance }) {
|
|||||||
name="refresh-1"
|
name="refresh-1"
|
||||||
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
|
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
|
||||||
size={40}
|
size={40}
|
||||||
onClick={e => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
/>
|
/>*/}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -4,16 +4,28 @@ import OsImageName from "./os-image-name";
|
|||||||
import VpsResources from "./vps-resources";
|
import VpsResources from "./vps-resources";
|
||||||
import VmActions from "./vps-actions";
|
import VmActions from "./vps-actions";
|
||||||
|
|
||||||
export default function VpsInstanceRow({ vm, actions }: { vm: VmInstance, actions?: boolean }) {
|
export default function VpsInstanceRow({
|
||||||
|
vm,
|
||||||
|
actions,
|
||||||
|
onReload,
|
||||||
|
}: {
|
||||||
|
vm: VmInstance;
|
||||||
|
actions?: boolean;
|
||||||
|
onReload?: () => void;
|
||||||
|
}) {
|
||||||
const expires = new Date(vm.expires);
|
const expires = new Date(vm.expires);
|
||||||
const isExpired = expires <= new Date();
|
const isExpired = expires <= new Date();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-center rounded-xl bg-neutral-900 px-3 py-2 cursor-pointer hover:bg-neutral-800"
|
<div
|
||||||
onClick={() => navigate("/vm", {
|
className="flex justify-between items-center rounded-xl bg-neutral-900 px-3 py-2 cursor-pointer hover:bg-neutral-800"
|
||||||
state: vm
|
onClick={() =>
|
||||||
})}>
|
navigate("/vm", {
|
||||||
|
state: vm,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-neutral-400">#{vm.id}</span>
|
<span className="text-sm text-neutral-400">#{vm.id}</span>
|
||||||
@ -26,7 +38,7 @@ export default function VpsInstanceRow({ vm, actions }: { vm: VmInstance, action
|
|||||||
</div>
|
</div>
|
||||||
<VpsResources vm={vm} />
|
<VpsResources vm={vm} />
|
||||||
</div>
|
</div>
|
||||||
{(actions ?? true) && <div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
{isExpired && (
|
{isExpired && (
|
||||||
<>
|
<>
|
||||||
<Link to="/vm/renew" className="text-red-500 text-sm" state={vm}>
|
<Link to="/vm/renew" className="text-red-500 text-sm" state={vm}>
|
||||||
@ -34,8 +46,10 @@ export default function VpsInstanceRow({ vm, actions }: { vm: VmInstance, action
|
|||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isExpired && <VmActions vm={vm} />}
|
{!isExpired && (actions ?? true) && (
|
||||||
</div>}
|
<VmActions vm={vm} onReload={onReload} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,8 +2,6 @@ import { useEffect } from "react";
|
|||||||
import { LNVpsApi, VmPayment } from "../api";
|
import { LNVpsApi, VmPayment } from "../api";
|
||||||
import QrCode from "./qr";
|
import QrCode from "./qr";
|
||||||
import useLogin from "../hooks/login";
|
import useLogin from "../hooks/login";
|
||||||
import { ApiUrl } from "../const";
|
|
||||||
import { EventPublisher } from "@snort/system";
|
|
||||||
|
|
||||||
export default function VpsPayment({
|
export default function VpsPayment({
|
||||||
payment,
|
payment,
|
||||||
@ -29,13 +27,10 @@ export default function VpsPayment({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!login?.signer) return;
|
if (!login?.api) return;
|
||||||
const api = new LNVpsApi(
|
|
||||||
ApiUrl,
|
|
||||||
new EventPublisher(login.signer, login.pubkey),
|
|
||||||
);
|
|
||||||
const tx = setInterval(async () => {
|
const tx = setInterval(async () => {
|
||||||
if (await checkPayment(api)) {
|
if (await checkPayment(login.api)) {
|
||||||
clearInterval(tx);
|
clearInterval(tx);
|
||||||
}
|
}
|
||||||
}, 2_000);
|
}, 2_000);
|
||||||
|
@ -12,8 +12,7 @@ export const GB = KB * 1000;
|
|||||||
export const TB = GB * 1000;
|
export const TB = GB * 1000;
|
||||||
export const PB = TB * 1000;
|
export const PB = TB * 1000;
|
||||||
|
|
||||||
export const ApiUrl = "http://localhost:8000";
|
export const ApiUrl = import.meta.env.VITE_API_URL;
|
||||||
//export const ApiUrl = "https://api.lnvps.net";
|
|
||||||
|
|
||||||
export const NostrProfile = new NostrLink(
|
export const NostrProfile = new NostrLink(
|
||||||
NostrPrefix.Profile,
|
NostrPrefix.Profile,
|
||||||
|
@ -1,9 +1,21 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useContext, useSyncExternalStore } from "react";
|
||||||
import { Login } from "../login";
|
import { LoginState } from "../login";
|
||||||
|
import { SnortContext } from "@snort/system-react";
|
||||||
|
import { LNVpsApi } from "../api";
|
||||||
|
import { ApiUrl } from "../const";
|
||||||
|
|
||||||
export default function useLogin() {
|
export default function useLogin() {
|
||||||
return useSyncExternalStore(
|
const session = useSyncExternalStore(
|
||||||
(c) => Login.hook(c),
|
(c) => LoginState.hook(c),
|
||||||
() => Login.snapshot(),
|
() => LoginState.snapshot(),
|
||||||
);
|
);
|
||||||
|
const system = useContext(SnortContext);
|
||||||
|
return session
|
||||||
|
? {
|
||||||
|
type: session.type,
|
||||||
|
publicKey: session.publicKey,
|
||||||
|
system,
|
||||||
|
api: new LNVpsApi(ApiUrl, LoginState.getSigner()),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
}
|
}
|
||||||
|
@ -35,3 +35,9 @@ a:hover {
|
|||||||
hr {
|
hr {
|
||||||
@apply border-neutral-800;
|
@apply border-neutral-800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
@apply border-none rounded-xl bg-neutral-900 p-2;
|
||||||
|
}
|
||||||
|
118
src/login.ts
118
src/login.ts
@ -1,38 +1,112 @@
|
|||||||
import { ExternalStore } from "@snort/shared";
|
import { ExternalStore } from "@snort/shared";
|
||||||
import {
|
import {
|
||||||
EventSigner,
|
EventPublisher,
|
||||||
|
Nip46Signer,
|
||||||
Nip7Signer,
|
Nip7Signer,
|
||||||
SystemInterface,
|
PrivateKeySigner,
|
||||||
UserState,
|
|
||||||
} from "@snort/system";
|
} from "@snort/system";
|
||||||
|
|
||||||
class LoginShell extends ExternalStore<UserState<void> | undefined> {
|
export interface LoginSession {
|
||||||
#state?: UserState<void>;
|
type: "nip7" | "nsec" | "nip46";
|
||||||
|
publicKey: string;
|
||||||
|
privateKey?: string;
|
||||||
|
bunker?: string;
|
||||||
|
}
|
||||||
|
class LoginStore extends ExternalStore<LoginSession | undefined> {
|
||||||
|
#session?: LoginSession;
|
||||||
|
#signer?: EventPublisher;
|
||||||
|
|
||||||
async login(signer: EventSigner, system: SystemInterface) {
|
constructor() {
|
||||||
if (this.#state !== undefined) {
|
super();
|
||||||
throw new Error("Already logged in");
|
const s = window.localStorage.getItem("session");
|
||||||
|
if (s) {
|
||||||
|
this.#session = JSON.parse(s);
|
||||||
|
// patch session
|
||||||
|
if (this.#session) {
|
||||||
|
this.#session.type ??= "nip7";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const pubkey = await signer.getPubKey();
|
|
||||||
this.#state = new UserState<void>(pubkey);
|
|
||||||
await this.#state.init(signer, system);
|
|
||||||
this.#state.on("change", () => this.notifyChange());
|
|
||||||
this.notifyChange();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
takeSnapshot() {
|
takeSnapshot() {
|
||||||
return this.#state;
|
return this.#session ? { ...this.#session } : undefined;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export const Login = new LoginShell();
|
logout() {
|
||||||
|
this.#session = undefined;
|
||||||
|
this.#signer = undefined;
|
||||||
|
this.#save();
|
||||||
|
}
|
||||||
|
|
||||||
export async function loginNip7(system: SystemInterface) {
|
login(pubkey: string, type: LoginSession["type"] = "nip7") {
|
||||||
const signer = new Nip7Signer();
|
this.#session = {
|
||||||
const pubkey = await signer.getPubKey();
|
type: type ?? "nip7",
|
||||||
if (pubkey) {
|
publicKey: pubkey,
|
||||||
await Login.login(signer, system);
|
};
|
||||||
|
this.#save();
|
||||||
|
}
|
||||||
|
|
||||||
|
loginPrivateKey(key: string) {
|
||||||
|
const s = new PrivateKeySigner(key);
|
||||||
|
this.#session = {
|
||||||
|
type: "nsec",
|
||||||
|
publicKey: s.getPubKey(),
|
||||||
|
privateKey: key,
|
||||||
|
};
|
||||||
|
this.#save();
|
||||||
|
}
|
||||||
|
|
||||||
|
loginBunker(url: string, localKey: string, remotePubkey: string) {
|
||||||
|
this.#session = {
|
||||||
|
type: "nip46",
|
||||||
|
publicKey: remotePubkey,
|
||||||
|
privateKey: localKey,
|
||||||
|
bunker: url,
|
||||||
|
};
|
||||||
|
this.#save();
|
||||||
|
}
|
||||||
|
|
||||||
|
getSigner() {
|
||||||
|
if (!this.#signer && this.#session) {
|
||||||
|
switch (this.#session.type) {
|
||||||
|
case "nsec":
|
||||||
|
this.#signer = new EventPublisher(
|
||||||
|
new PrivateKeySigner(this.#session.privateKey!),
|
||||||
|
this.#session.publicKey,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "nip46":
|
||||||
|
this.#signer = new EventPublisher(
|
||||||
|
new Nip46Signer(
|
||||||
|
this.#session.bunker!,
|
||||||
|
new PrivateKeySigner(this.#session.privateKey!),
|
||||||
|
),
|
||||||
|
this.#session.publicKey,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "nip7":
|
||||||
|
this.#signer = new EventPublisher(
|
||||||
|
new Nip7Signer(),
|
||||||
|
this.#session.publicKey,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#signer) {
|
||||||
|
return this.#signer;
|
||||||
|
}
|
||||||
|
throw "Signer not setup!";
|
||||||
|
}
|
||||||
|
|
||||||
|
#save() {
|
||||||
|
if (this.#session) {
|
||||||
|
window.localStorage.setItem("session", JSON.stringify(this.#session));
|
||||||
} else {
|
} else {
|
||||||
throw new Error("No nostr extension found");
|
window.localStorage.removeItem("session");
|
||||||
|
}
|
||||||
|
this.notifyChange();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const LoginState = new LoginStore();
|
||||||
|
@ -9,6 +9,7 @@ import HomePage from "./pages/home.tsx";
|
|||||||
import OrderPage from "./pages/order.tsx";
|
import OrderPage from "./pages/order.tsx";
|
||||||
import VmPage from "./pages/vm.tsx";
|
import VmPage from "./pages/vm.tsx";
|
||||||
import AccountPage from "./pages/account.tsx";
|
import AccountPage from "./pages/account.tsx";
|
||||||
|
import SignUpPage from "./pages/sign-up.tsx";
|
||||||
|
|
||||||
const system = new NostrSystem({
|
const system = new NostrSystem({
|
||||||
automaticOutboxModel: false,
|
automaticOutboxModel: false,
|
||||||
@ -29,6 +30,10 @@ const router = createBrowserRouter([
|
|||||||
path: "/",
|
path: "/",
|
||||||
element: <HomePage />,
|
element: <HomePage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/login",
|
||||||
|
element: <SignUpPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/account",
|
path: "/account",
|
||||||
element: <AccountPage />,
|
element: <AccountPage />,
|
||||||
|
@ -1,21 +1,22 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LNVpsApi, VmInstance } from "../api";
|
import { VmInstance } from "../api";
|
||||||
import useLogin from "../hooks/login";
|
import useLogin from "../hooks/login";
|
||||||
import { EventPublisher } from "@snort/system";
|
|
||||||
import { ApiUrl } from "../const";
|
|
||||||
import VpsInstanceRow from "../components/vps-instance";
|
import VpsInstanceRow from "../components/vps-instance";
|
||||||
|
|
||||||
export default function AccountPage() {
|
export default function AccountPage() {
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const [vms, setVms] = useState<Array<VmInstance>>([]);
|
const [vms, setVms] = useState<Array<VmInstance>>([]);
|
||||||
|
|
||||||
|
async function loadVms() {
|
||||||
|
if (!login?.api) return;
|
||||||
|
const vms = await login?.api.listVms();
|
||||||
|
setVms(vms);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!login?.signer) return;
|
loadVms();
|
||||||
const api = new LNVpsApi(
|
const t = setInterval(() => loadVms(), 5_000);
|
||||||
ApiUrl,
|
return () => clearInterval(t);
|
||||||
new EventPublisher(login.signer, login.pubkey),
|
|
||||||
);
|
|
||||||
api.listVms().then(setVms);
|
|
||||||
}, [login]);
|
}, [login]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -23,7 +24,7 @@ export default function AccountPage() {
|
|||||||
<h3>My Resources</h3>
|
<h3>My Resources</h3>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{vms.map((a) => (
|
{vms.map((a) => (
|
||||||
<VpsInstanceRow key={a.id} vm={a} actions={false} />
|
<VpsInstanceRow key={a.id} vm={a} onReload={loadVms} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -34,7 +34,7 @@ export default function HomePage() {
|
|||||||
<a target="_blank" href="http://speedtest.v0l.io">
|
<a target="_blank" href="http://speedtest.v0l.io">
|
||||||
Speedtest
|
Speedtest
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
| <a href="/public/lnvps.asc">PGP</a>
|
| <a href="/lnvps.asc">PGP</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,79 +1,35 @@
|
|||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { LNVpsApi, UserSshKey, VmOsImage, VmTemplate } from "../api";
|
import { VmOsImage, VmTemplate } from "../api";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import CostLabel from "../components/cost";
|
import CostLabel from "../components/cost";
|
||||||
import { ApiUrl } from "../const";
|
|
||||||
import { EventPublisher } from "@snort/system";
|
|
||||||
import useLogin from "../hooks/login";
|
import useLogin from "../hooks/login";
|
||||||
import { AsyncButton } from "../components/button";
|
import { AsyncButton } from "../components/button";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import VpsResources from "../components/vps-resources";
|
import VpsResources from "../components/vps-resources";
|
||||||
import OsImageName from "../components/os-image-name";
|
import OsImageName from "../components/os-image-name";
|
||||||
|
import SSHKeySelector from "../components/ssh-keys";
|
||||||
|
|
||||||
export default function OrderPage() {
|
export default function OrderPage() {
|
||||||
const { state } = useLocation();
|
const { state } = useLocation();
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const template = state as VmTemplate | undefined;
|
const template = state as VmTemplate | undefined;
|
||||||
const [newKey, setNewKey] = useState("");
|
|
||||||
const [newKeyError, setNewKeyError] = useState("");
|
|
||||||
const [newKeyName, setNewKeyName] = useState("");
|
|
||||||
const [useImage, setUseImage] = useState(-1);
|
const [useImage, setUseImage] = useState(-1);
|
||||||
const [useSshKey, setUseSshKey] = useState(-1);
|
const [useSshKey, setUseSshKey] = useState(-1);
|
||||||
const [showAddKey, setShowAddKey] = useState(false);
|
|
||||||
const [images, setImages] = useState<Array<VmOsImage>>([]);
|
const [images, setImages] = useState<Array<VmOsImage>>([]);
|
||||||
const [sshKeys, setSshKeys] = useState<Array<UserSshKey>>([]);
|
|
||||||
const [orderError, setOrderError] = useState("");
|
const [orderError, setOrderError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!login?.signer) return;
|
if (!login?.api) return;
|
||||||
const api = new LNVpsApi(
|
login.api.listOsImages().then((a) => setImages(a));
|
||||||
ApiUrl,
|
|
||||||
new EventPublisher(login.signer, login.pubkey),
|
|
||||||
);
|
|
||||||
api.listOsImages().then((a) => setImages(a));
|
|
||||||
api.listSshKeys().then((a) => {
|
|
||||||
setSshKeys(a);
|
|
||||||
if (a.length > 0) {
|
|
||||||
setUseSshKey(a[0].id);
|
|
||||||
} else {
|
|
||||||
setShowAddKey(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [login]);
|
}, [login]);
|
||||||
|
|
||||||
async function addNewKey() {
|
|
||||||
if (!login?.signer) return;
|
|
||||||
setNewKeyError("");
|
|
||||||
const api = new LNVpsApi(
|
|
||||||
ApiUrl,
|
|
||||||
new EventPublisher(login.signer, login.pubkey),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const nk = await api.addSshKey(newKeyName, newKey);
|
|
||||||
setNewKey("");
|
|
||||||
setNewKeyName("");
|
|
||||||
setUseSshKey(nk.id);
|
|
||||||
setShowAddKey(false);
|
|
||||||
api.listSshKeys().then((a) => setSshKeys(a));
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error) {
|
|
||||||
setNewKeyError(e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createOrder() {
|
async function createOrder() {
|
||||||
if (!login?.signer || !template) return;
|
if (!login?.api || !template) return;
|
||||||
const api = new LNVpsApi(
|
|
||||||
ApiUrl,
|
|
||||||
new EventPublisher(login.signer, login.pubkey),
|
|
||||||
);
|
|
||||||
|
|
||||||
setOrderError("");
|
setOrderError("");
|
||||||
try {
|
try {
|
||||||
const newVm = await api.orderVm(template.id, useImage, useSshKey);
|
const newVm = await login.api.orderVm(template.id, useImage, useSshKey);
|
||||||
navigate("/vm/renew", {
|
navigate("/vm/renew", {
|
||||||
state: newVm,
|
state: newVm,
|
||||||
});
|
});
|
||||||
@ -122,53 +78,7 @@ export default function OrderPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<div className="flex flex-col gap-2">
|
<SSHKeySelector selectedKey={useSshKey} setSelectedKey={setUseSshKey} />
|
||||||
{sshKeys.length > 0 && (
|
|
||||||
<>
|
|
||||||
<b>Select SSH Key:</b>
|
|
||||||
<select
|
|
||||||
className="bg-neutral-900 p-2 rounded-xl"
|
|
||||||
value={useSshKey}
|
|
||||||
onChange={(e) => setUseSshKey(Number(e.target.value))}
|
|
||||||
>
|
|
||||||
{sshKeys.map((a) => (
|
|
||||||
<option value={a.id}>{a.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!showAddKey && sshKeys.length > 0 && (
|
|
||||||
<AsyncButton onClick={() => setShowAddKey(true)}>
|
|
||||||
Add new SSH key
|
|
||||||
</AsyncButton>
|
|
||||||
)}
|
|
||||||
{(showAddKey || sshKeys.length === 0) && (
|
|
||||||
<>
|
|
||||||
<b>Add SSH Key:</b>
|
|
||||||
<textarea
|
|
||||||
className="border-none rounded-xl bg-neutral-900 p-2"
|
|
||||||
rows={5}
|
|
||||||
placeholder="ssh-[rsa|ed25519] AA== id"
|
|
||||||
value={newKey}
|
|
||||||
onChange={(e) => setNewKey(e.target.value)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="border-none rounded-xl bg-neutral-900 p-2"
|
|
||||||
placeholder="Key name"
|
|
||||||
value={newKeyName}
|
|
||||||
onChange={(e) => setNewKeyName(e.target.value)}
|
|
||||||
/>
|
|
||||||
<AsyncButton
|
|
||||||
disabled={newKey.length < 10 || newKeyName.length < 2}
|
|
||||||
onClick={addNewKey}
|
|
||||||
>
|
|
||||||
Add Key
|
|
||||||
</AsyncButton>
|
|
||||||
{newKeyError && <b className="text-red-500">{newKeyError}</b>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<AsyncButton
|
<AsyncButton
|
||||||
disabled={useSshKey === -1 || useImage === -1}
|
disabled={useSshKey === -1 || useImage === -1}
|
||||||
onClick={createOrder}
|
onClick={createOrder}
|
||||||
|
174
src/pages/sign-up.tsx
Normal file
174
src/pages/sign-up.tsx
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import {
|
||||||
|
EventPublisher,
|
||||||
|
Nip46Signer,
|
||||||
|
Nip7Signer,
|
||||||
|
PrivateKeySigner,
|
||||||
|
} from "@snort/system";
|
||||||
|
import { AsyncButton } from "../components/button";
|
||||||
|
import { useContext, useState } from "react";
|
||||||
|
import { bech32ToHex, hexToBech32 } from "@snort/shared";
|
||||||
|
import { openFile } from "../utils";
|
||||||
|
import { SnortContext } from "@snort/system-react";
|
||||||
|
import { Blossom } from "../blossom";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { LoginState } from "../login";
|
||||||
|
|
||||||
|
export default function SignUpPage() {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [keyIn, setKeyIn] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [file, setFile] = useState<File>();
|
||||||
|
const [key, setKey] = useState<PrivateKeySigner>();
|
||||||
|
const system = useContext(SnortContext);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function uploadImage() {
|
||||||
|
const f = await openFile();
|
||||||
|
setFile(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spawnAccount() {
|
||||||
|
if (!key) return;
|
||||||
|
setError("");
|
||||||
|
const pub = new EventPublisher(key, key.getPubKey());
|
||||||
|
|
||||||
|
let pic = undefined;
|
||||||
|
if (file) {
|
||||||
|
// upload picture
|
||||||
|
const b = new Blossom("https://nostr.download", pub);
|
||||||
|
const up = await b.upload(file);
|
||||||
|
if (up.url) {
|
||||||
|
pic = up.url;
|
||||||
|
} else {
|
||||||
|
setError("Upload filed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ev = await pub.metadata({
|
||||||
|
name: name,
|
||||||
|
picture: pic,
|
||||||
|
});
|
||||||
|
system.BroadcastEvent(ev);
|
||||||
|
LoginState.loginPrivateKey(key.privateKey);
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginKey() {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
if (keyIn.startsWith("nsec1")) {
|
||||||
|
LoginState.loginPrivateKey(bech32ToHex(keyIn));
|
||||||
|
navigate("/");
|
||||||
|
} else if (keyIn.startsWith("bunker://")) {
|
||||||
|
const signer = new Nip46Signer(keyIn);
|
||||||
|
await signer.init();
|
||||||
|
const pubkey = await signer.getPubKey();
|
||||||
|
LoginState.loginBunker(keyIn, signer.privateKey!, pubkey);
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{error && <b className="text-red-500">{error}</b>}
|
||||||
|
<h1>Login</h1>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="nsec/bunker"
|
||||||
|
value={keyIn}
|
||||||
|
onChange={(e) => setKeyIn(e.target.value)}
|
||||||
|
/>
|
||||||
|
<AsyncButton
|
||||||
|
onClick={loginKey}
|
||||||
|
disabled={!keyIn.startsWith("nsec") && !keyIn.startsWith("bunker://")}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</AsyncButton>
|
||||||
|
{window.nostr && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
Browser Extension:
|
||||||
|
<AsyncButton
|
||||||
|
onClick={async () => {
|
||||||
|
const pk = await new Nip7Signer().getPubKey();
|
||||||
|
LoginState.login(pk);
|
||||||
|
navigate("/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Nostr Extension
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-4 items-center my-6">
|
||||||
|
<div className="text-xl">OR</div>
|
||||||
|
<div className="h-[1px] bg-neutral-800 w-full"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Create Account</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
LNVPS uses nostr accounts,{" "}
|
||||||
|
<a
|
||||||
|
href="https://nostr.how/en/what-is-nostr"
|
||||||
|
target="_blank"
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
what is nostr?
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div>Avatar</div>
|
||||||
|
<div
|
||||||
|
className="w-40 h-40 bg-neutral-900 rounded-xl relative cursor-pointer overflow-hidden"
|
||||||
|
onClick={uploadImage}
|
||||||
|
>
|
||||||
|
<div className="absolute bg-black/50 w-full h-full hover:opacity-90 opacity-0 flex items-center justify-center">
|
||||||
|
Upload
|
||||||
|
</div>
|
||||||
|
{file && (
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(file)}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div>Name</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Display name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AsyncButton
|
||||||
|
onClick={async () => {
|
||||||
|
setKey(PrivateKeySigner.random());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</AsyncButton>
|
||||||
|
|
||||||
|
{key && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3>Your new key:</h3>
|
||||||
|
<div className="font-monospace select-all">
|
||||||
|
{hexToBech32("nsec", key.privateKey)}
|
||||||
|
</div>
|
||||||
|
<b>Please save this key, it CANNOT be recovered</b>
|
||||||
|
</div>
|
||||||
|
<AsyncButton onClick={spawnAccount}>Login</AsyncButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
137
src/pages/vm.tsx
137
src/pages/vm.tsx
@ -1,13 +1,21 @@
|
|||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
import { LNVpsApi, VmInstance, VmPayment } from "../api";
|
import { VmInstance, VmPayment } from "../api";
|
||||||
import VpsInstanceRow from "../components/vps-instance";
|
import VpsInstanceRow from "../components/vps-instance";
|
||||||
import useLogin from "../hooks/login";
|
import useLogin from "../hooks/login";
|
||||||
import { ApiUrl } from "../const";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { EventPublisher } from "@snort/system";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import VpsPayment from "../components/vps-payment";
|
import VpsPayment from "../components/vps-payment";
|
||||||
import CostLabel from "../components/cost";
|
import CostLabel from "../components/cost";
|
||||||
import { AsyncButton } from "../components/button";
|
import { AsyncButton } from "../components/button";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import { toEui64 } from "../utils";
|
||||||
|
import { Icon } from "../components/icon";
|
||||||
|
import Modal from "../components/modal";
|
||||||
|
import SSHKeySelector from "../components/ssh-keys";
|
||||||
|
|
||||||
|
const fit = new FitAddon();
|
||||||
|
|
||||||
export default function VmPage() {
|
export default function VmPage() {
|
||||||
const { state } = useLocation() as { state?: VmInstance };
|
const { state } = useLocation() as { state?: VmInstance };
|
||||||
@ -15,20 +23,46 @@ export default function VmPage() {
|
|||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [payment, setPayment] = useState<VmPayment>();
|
const [payment, setPayment] = useState<VmPayment>();
|
||||||
|
const [term] = useState<Terminal>();
|
||||||
|
const termRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [editKey, setEditKey] = useState(false);
|
||||||
|
const [key, setKey] = useState(state?.ssh_key_id ?? -1);
|
||||||
|
|
||||||
const renew = useCallback(
|
const renew = useCallback(
|
||||||
async function () {
|
async function () {
|
||||||
if (!login?.signer || !state) return;
|
if (!login?.api || !state) return;
|
||||||
const api = new LNVpsApi(
|
const p = await login?.api.renewVm(state.id);
|
||||||
ApiUrl,
|
|
||||||
new EventPublisher(login.signer, login.pubkey),
|
|
||||||
);
|
|
||||||
const p = await api.renewVm(state.id);
|
|
||||||
setPayment(p);
|
setPayment(p);
|
||||||
},
|
},
|
||||||
[login, state],
|
[login?.api, state],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/*async function openTerminal() {
|
||||||
|
if (!login?.api || !state) return;
|
||||||
|
const ws = await login.api.connect_terminal(state.id);
|
||||||
|
const te = new Terminal();
|
||||||
|
const webgl = new WebglAddon();
|
||||||
|
webgl.onContextLoss(() => {
|
||||||
|
webgl.dispose();
|
||||||
|
});
|
||||||
|
te.loadAddon(webgl);
|
||||||
|
te.loadAddon(fit);
|
||||||
|
te.onResize(({ cols, rows }) => {
|
||||||
|
ws.send(`${cols}:${rows}`);
|
||||||
|
});
|
||||||
|
const attach = new AttachAddon(ws);
|
||||||
|
te.loadAddon(attach);
|
||||||
|
setTerm(te);
|
||||||
|
}*/
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (term && termRef.current) {
|
||||||
|
term.open(termRef.current);
|
||||||
|
term.focus();
|
||||||
|
fit.fit();
|
||||||
|
}
|
||||||
|
}, [termRef, term, fit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "renew":
|
case "renew":
|
||||||
@ -41,24 +75,51 @@ export default function VmPage() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<VpsInstanceRow vm={state} actions={false} />
|
<VpsInstanceRow vm={state} actions={true} />
|
||||||
{action === undefined && <>
|
{action === undefined && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="text-xl">Network:</div>
|
||||||
|
{(state.ip_assignments?.length ?? 0) === 0 && (
|
||||||
|
<div className="text-sm text-red-500">No IP's assigned</div>
|
||||||
|
)}
|
||||||
|
{state.ip_assignments?.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
className="text-sm bg-neutral-900 px-3 py-1 rounded-lg"
|
||||||
|
>
|
||||||
|
{a.ip.split("/")[0]}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="text-sm bg-neutral-900 px-3 py-1 rounded-lg">
|
||||||
|
{toEui64("2a13:2c0::", state.mac_address)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="text-xl">SSH Key:</div>
|
||||||
|
<div className="text-sm bg-neutral-900 px-3 py-1 rounded-lg">
|
||||||
|
{state.ssh_key?.name}
|
||||||
|
</div>
|
||||||
|
<Icon name="pencil" onClick={() => setEditKey(true)} />
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
<div className="text-xl">Renewal</div>
|
<div className="text-xl">Renewal</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>{new Date(state.expires).toDateString()}</div>
|
<div>{new Date(state.expires).toDateString()}</div>
|
||||||
{state.template?.cost_plan && <div><CostLabel cost={state.template?.cost_plan} /></div>}
|
{state.template?.cost_plan && (
|
||||||
|
<div>
|
||||||
|
<CostLabel cost={state.template?.cost_plan} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<AsyncButton onClick={() => navigate("/vm/renew", { state })}>
|
<AsyncButton onClick={() => navigate("/vm/renew", { state })}>
|
||||||
Extend Now
|
Extend Now
|
||||||
</AsyncButton>
|
</AsyncButton>
|
||||||
<div className="text-xl">Network</div>
|
{/*
|
||||||
<div className="flex gap-4">
|
{!term && <AsyncButton onClick={openTerminal}>Connect Terminal</AsyncButton>}
|
||||||
{(state.ip_assignments?.length ?? 0) === 0 && <div className="text-sm text-red-500">No IP's assigned</div>}
|
{term && <div className="border p-2" ref={termRef}></div>}*/}
|
||||||
{state.ip_assignments?.map(a => <div key={a.id} className="text-sm bg-neutral-900 px-3 py-1 rounded-lg">
|
</>
|
||||||
{a.ip.split("/")[0]}
|
)}
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
</>}
|
|
||||||
{action === "renew" && (
|
{action === "renew" && (
|
||||||
<>
|
<>
|
||||||
<h3>Renew VPS</h3>
|
<h3>Renew VPS</h3>
|
||||||
@ -66,12 +127,8 @@ export default function VmPage() {
|
|||||||
<VpsPayment
|
<VpsPayment
|
||||||
payment={payment}
|
payment={payment}
|
||||||
onPaid={async () => {
|
onPaid={async () => {
|
||||||
if (!login?.signer || !state) return;
|
if (!login?.api || !state) return;
|
||||||
const api = new LNVpsApi(
|
const newState = await login?.api.getVm(state.id);
|
||||||
ApiUrl,
|
|
||||||
new EventPublisher(login.signer, login.pubkey),
|
|
||||||
);
|
|
||||||
const newState = await api.getVm(state.id);
|
|
||||||
navigate("/vm", {
|
navigate("/vm", {
|
||||||
state: newState,
|
state: newState,
|
||||||
});
|
});
|
||||||
@ -81,6 +138,30 @@ export default function VmPage() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{editKey && (
|
||||||
|
<Modal id="edit-ssh-key" onClose={() => setEditKey(false)}>
|
||||||
|
<SSHKeySelector selectedKey={key} setSelectedKey={setKey} />
|
||||||
|
<div className="flex flex-col gap-4 mt-8">
|
||||||
|
<small>After selecting a new key, please restart the VM.</small>
|
||||||
|
<AsyncButton
|
||||||
|
onClick={async () => {
|
||||||
|
if (!login?.api) return;
|
||||||
|
await login.api.patchVm(state.id, {
|
||||||
|
ssh_key_id: key,
|
||||||
|
});
|
||||||
|
const ns = await login.api.getVm(state?.id);
|
||||||
|
navigate(".", {
|
||||||
|
state: ns,
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
setEditKey(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
53
src/utils.ts
Normal file
53
src/utils.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { base16 } from "@scure/base";
|
||||||
|
|
||||||
|
export async function openFile(): Promise<File | undefined> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const elm = document.createElement("input");
|
||||||
|
let lock = false;
|
||||||
|
elm.type = "file";
|
||||||
|
const handleInput = (e: Event) => {
|
||||||
|
lock = true;
|
||||||
|
const elm = e.target as HTMLInputElement;
|
||||||
|
if ((elm.files?.length ?? 0) > 0) {
|
||||||
|
resolve(elm.files![0]);
|
||||||
|
} else {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
elm.onchange = (e) => handleInput(e);
|
||||||
|
elm.click();
|
||||||
|
window.addEventListener(
|
||||||
|
"focus",
|
||||||
|
() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!lock) {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toEui64(prefix: string, mac: string) {
|
||||||
|
const macData = base16.decode(mac.replace(/:/g, "").toUpperCase());
|
||||||
|
const macExtended = new Uint8Array([
|
||||||
|
...macData.subarray(0, 3),
|
||||||
|
0xff,
|
||||||
|
0xfe,
|
||||||
|
...macData.subarray(3, 6),
|
||||||
|
]);
|
||||||
|
macExtended[0] |= 0x02;
|
||||||
|
return (
|
||||||
|
prefix +
|
||||||
|
base16.encode(macExtended.subarray(0, 2)) +
|
||||||
|
":" +
|
||||||
|
base16.encode(macExtended.subarray(2, 4)) +
|
||||||
|
":" +
|
||||||
|
base16.encode(macExtended.subarray(4, 6)) +
|
||||||
|
":" +
|
||||||
|
base16.encode(macExtended.subarray(6, 8))
|
||||||
|
).toLowerCase();
|
||||||
|
}
|
60
yarn.lock
60
yarn.lock
@ -758,20 +758,20 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@snort/system-react@npm:^1.5.7":
|
"@snort/system-react@npm:^1.6.1":
|
||||||
version: 1.5.7
|
version: 1.6.1
|
||||||
resolution: "@snort/system-react@npm:1.5.7"
|
resolution: "@snort/system-react@npm:1.6.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@snort/shared": "npm:^1.0.17"
|
"@snort/shared": "npm:^1.0.17"
|
||||||
"@snort/system": "npm:^1.5.7"
|
"@snort/system": "npm:^1.6.1"
|
||||||
react: "npm:^18.2.0"
|
react: "npm:^18.2.0"
|
||||||
checksum: 10c0/b8261d72bef88fc6baa91f3f3765a7e65e7775e0f87142079ec425fdc3639871da068b2bfc9c4a2c0f15a20c7c76ffe84d9bc8cbc1e6ef5a76989cd78d3f7049
|
checksum: 10c0/fe3180c1f4341df4fd585c4031ef632a7d59c7637c6aed16ea254a1c22a66e288516d96939c0080efd0fb73940531855a59a24fae5f82c326f6544362eb0394c
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@snort/system@npm:^1.5.7":
|
"@snort/system@npm:^1.6.1":
|
||||||
version: 1.5.7
|
version: 1.6.1
|
||||||
resolution: "@snort/system@npm:1.5.7"
|
resolution: "@snort/system@npm:1.6.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@noble/ciphers": "npm:^0.6.0"
|
"@noble/ciphers": "npm:^0.6.0"
|
||||||
"@noble/curves": "npm:^1.4.0"
|
"@noble/curves": "npm:^1.4.0"
|
||||||
@ -786,7 +786,7 @@ __metadata:
|
|||||||
nostr-social-graph: "npm:^1.0.3"
|
nostr-social-graph: "npm:^1.0.3"
|
||||||
uuid: "npm:^9.0.0"
|
uuid: "npm:^9.0.0"
|
||||||
ws: "npm:^8.14.0"
|
ws: "npm:^8.14.0"
|
||||||
checksum: 10c0/9b1d6e36dfc3c0845754d4f2c10eb39665a2c4c4c61a07635e0b792a352f8566dbd79561561568c182272cd92b0d2c421ef137775b16872b7e28fa39366e2094
|
checksum: 10c0/5da01450970f4a51df98369c4cdd2b8886add3480e7962d11ed17498cf7db421bcd5e585abe532ff754efc0bf26b5734b9b24005f958802357ec390ccb74d281
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -1046,6 +1046,40 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@xterm/addon-attach@npm:^0.11.0":
|
||||||
|
version: 0.11.0
|
||||||
|
resolution: "@xterm/addon-attach@npm:0.11.0"
|
||||||
|
peerDependencies:
|
||||||
|
"@xterm/xterm": ^5.0.0
|
||||||
|
checksum: 10c0/7646e4a4ec1588f922bfed81e0361db435e4db73656847eda1f5724f7580a522b0ad9b636c76ddb3fad785feb4c594cc813b7e7a990d780d2d7aa78c69ad092f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@xterm/addon-fit@npm:^0.10.0":
|
||||||
|
version: 0.10.0
|
||||||
|
resolution: "@xterm/addon-fit@npm:0.10.0"
|
||||||
|
peerDependencies:
|
||||||
|
"@xterm/xterm": ^5.0.0
|
||||||
|
checksum: 10c0/76926120fc940376afef2cb68b15aec2a99fc628b6e3cc84f2bcb1682ca9b87f982b3c10ff206faf4ebc5b410467b81a7b5e83be37b4ac386586f472e4fa1c61
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@xterm/addon-webgl@npm:^0.18.0":
|
||||||
|
version: 0.18.0
|
||||||
|
resolution: "@xterm/addon-webgl@npm:0.18.0"
|
||||||
|
peerDependencies:
|
||||||
|
"@xterm/xterm": ^5.0.0
|
||||||
|
checksum: 10c0/682a3f5f128ee09a0cf1b41cbb7b2f925a5e43056e12ba0c523b93a1f5f188045caef9e31f32db933b8a7a1b12d8f9babaddfa11e6f11df0c7b265009103476c
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@xterm/xterm@npm:^5.5.0":
|
||||||
|
version: 5.5.0
|
||||||
|
resolution: "@xterm/xterm@npm:5.5.0"
|
||||||
|
checksum: 10c0/358801feece58617d777b2783bec68dac1f52f736da3b0317f71a34f4e25431fb0b1920244f678b8d673f797145b4858c2a5ccb463a4a6df7c10c9093f1c9267
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"abbrev@npm:^2.0.0":
|
"abbrev@npm:^2.0.0":
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
resolution: "abbrev@npm:2.0.0"
|
resolution: "abbrev@npm:2.0.0"
|
||||||
@ -2345,11 +2379,15 @@ __metadata:
|
|||||||
"@eslint/js": "npm:^9.8.0"
|
"@eslint/js": "npm:^9.8.0"
|
||||||
"@scure/base": "npm:^1.2.1"
|
"@scure/base": "npm:^1.2.1"
|
||||||
"@snort/shared": "npm:^1.0.17"
|
"@snort/shared": "npm:^1.0.17"
|
||||||
"@snort/system": "npm:^1.5.7"
|
"@snort/system": "npm:^1.6.1"
|
||||||
"@snort/system-react": "npm:^1.5.7"
|
"@snort/system-react": "npm:^1.6.1"
|
||||||
"@types/react": "npm:^18.3.3"
|
"@types/react": "npm:^18.3.3"
|
||||||
"@types/react-dom": "npm:^18.3.0"
|
"@types/react-dom": "npm:^18.3.0"
|
||||||
"@vitejs/plugin-react": "npm:^4.3.1"
|
"@vitejs/plugin-react": "npm:^4.3.1"
|
||||||
|
"@xterm/addon-attach": "npm:^0.11.0"
|
||||||
|
"@xterm/addon-fit": "npm:^0.10.0"
|
||||||
|
"@xterm/addon-webgl": "npm:^0.18.0"
|
||||||
|
"@xterm/xterm": "npm:^5.5.0"
|
||||||
autoprefixer: "npm:^10.4.20"
|
autoprefixer: "npm:^10.4.20"
|
||||||
classnames: "npm:^2.5.1"
|
classnames: "npm:^2.5.1"
|
||||||
eslint: "npm:^9.8.0"
|
eslint: "npm:^9.8.0"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user