Compare commits

...

59 Commits

Author SHA1 Message Date
0f8ee33279 feat: show lnurl as payment method
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-02 13:59:37 +01:00
f28c785cbd feat: show default username for ssh
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-02 09:50:10 +01:00
e4bee1a568 fix: icons
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-01 17:59:59 +01:00
51d7cea581 feat: navigate invoice
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-01 17:47:00 +01:00
9a04548627 feat: payment history 2025-05-01 15:32:00 +01:00
93de2704cc feat: billing info
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-01 14:51:20 +01:00
d2ee1acae7 feat: nostr domain
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-03 12:56:55 +01:00
1834242198 fix: revolut order amount
All checks were successful
continuous-integration/drone/push Build is passing
closes #21
2025-04-02 10:33:21 +01:00
5176386849 feat: filter disk type on templates
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-02 09:56:00 +01:00
db66cd4dc3 feat: filter custom order disk types
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-01 11:40:36 +01:00
74acc4ee42 fix: fotter notes
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-28 14:48:15 +00:00
9d70de9b8a feat: currency selector
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-28 14:29:00 +00:00
c67dd4c793 fix: build
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-28 12:26:54 +00:00
63c737b160 feat: remove hard-coded ip6 addr
Some checks failed
continuous-integration/drone/push Build is failing
2025-03-28 10:31:21 +00:00
a3836f445e fix: build
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-26 16:55:22 +00:00
0042a706bc fix: pcie
Some checks failed
continuous-integration/drone/push Build is failing
2025-03-24 16:15:08 +00:00
3c3218044b feat: filter regions 2025-03-24 16:10:42 +00:00
c0b7836ce3 feat: console progress
Some checks failed
continuous-integration/drone/push Build is failing
2025-03-24 13:04:55 +00:00
1f38e22053 feat: re-install
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-20 12:31:17 +00:00
26d36adbeb feat: taxes
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
ref: LNVPS/api#18
2025-03-11 15:59:32 +00:00
c1312d97f1 feat: revolut pay
All checks were successful
continuous-integration/drone/push Build is passing
ref: LNVPS/api#24
2025-03-11 12:42:16 +00:00
57cc619b8c feat: alt prices
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-10 15:10:47 +00:00
7cba506d6b feat: latest news on homepage
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-06 22:32:09 +00:00
8e3e4c0364 feat: custom pricing
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-06 21:43:31 +00:00
1aab7c9372 feat: news page
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-06 11:00:05 +00:00
0b93b0d4f9 fix: graph label
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-05 16:37:14 +00:00
c6e4a9e3c9 chore: formatting
Some checks failed
continuous-integration/drone/push Build is failing
2025-03-05 16:26:47 +00:00
7ba2659fbf feat: graphs 2025-03-05 16:26:04 +00:00
b52735a0a4 feat: new billing page 2025-03-05 15:33:32 +00:00
7bdea28bc9 fix: show no offers message
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-05 14:15:08 +00:00
d05c69af9c feat: ref codes
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-05 11:10:28 +00:00
c5d45b0843 fix: state reload 2025-03-05 10:59:49 +00:00
072e791d2c feat: reload vm state
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-05 10:21:47 +00:00
669b852106 feat: edit reverse dns
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-04 14:29:09 +00:00
cea6beee73 feat: show invoice string
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-01 21:46:00 +00:00
5b3ff37ca0 chore: use github link
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-28 12:11:21 +00:00
43886867e3 feat: login to order
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-28 09:52:01 +00:00
88c8574966 fix: speedtest link
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-26 11:57:13 +00:00
13353251ed chore: formatting
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-26 11:54:32 +00:00
7af41a1480 feat: custom status page
feat: logout
2025-02-26 11:54:10 +00:00
912bb21022 feat: add TOS
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-26 10:58:00 +00:00
bfb2b072f9 feat: add company info
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-26 10:34:22 +00:00
5af12b4f07 fix: ssh_key_id
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-25 15:15:20 +00:00
fd1df3ce3d fix: update to match new api models
Some checks failed
continuous-integration/drone/push Build is failing
2025-02-25 15:04:13 +00:00
8758116520 feat: notification settings
All checks were successful
continuous-integration/drone Build is passing
2025-02-21 11:43:16 +00:00
31f0a3c925 feat: add npub to account page
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-20 21:55:10 +00:00
5ecc59cda7 Link status page
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-18 12:30:39 +00:00
247b4c61c8 feat: add contact for account page
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-17 16:02:09 +00:00
9c84a927d3 feat: show resources from template
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-17 15:55:49 +00:00
9f364d2854 fix: signer
All checks were successful
continuous-integration/drone Build is passing
2024-12-29 19:27:29 +00:00
e9d88279cf fix: build 2024-12-29 19:25:14 +00:00
f4227fa121 feat: edit ssh key 2024-12-29 19:15:04 +00:00
1510b16ceb feat: show more VM info 2024-12-29 16:11:05 +00:00
fead7f1bff fix: icons 2024-11-29 17:38:04 +00:00
658e4aa5f2 feat: start/stop vm 2024-11-29 17:34:02 +00:00
fc1962defc feat: signup 2024-11-29 10:47:40 +00:00
12c3ddc31d fix: expired link 2024-11-27 14:39:57 +00:00
e4f6863e21 feat: add nginx conf 2024-11-26 19:17:48 +00:00
8ee0748740 fix: undo const 2024-11-26 19:15:50 +00:00
61 changed files with 4118 additions and 578 deletions

View File

@ -16,6 +16,5 @@ steps:
commands:
- dockerd &
- docker login -u registry -p $TOKEN registry.v0l.io
- docker build -t registry.v0l.io/lnvps-web:latest .
- docker push registry.v0l.io/lnvps-web:latest
- docker build -t registry.v0l.io/lnvps-web:latest --build-arg MODE=lnvps --push .
- kill $(cat /var/run/docker.pid)

3
.env Normal file
View File

@ -0,0 +1,3 @@
VITE_API_URL="https://api.lnvps.net"
VITE_FOOTER_NOTE_1=""
VITE_FOOTER_NOTE_2=""

2
.env.development Normal file
View File

@ -0,0 +1,2 @@
VITE_API_URL="http://localhost:8000"
VITE_REVOLUT_MODE="sandbox"

2
.env.lnvps Normal file
View File

@ -0,0 +1,2 @@
VITE_FOOTER_NOTE_1="LNVPS is a trading name of Apex Strata Ltd, a company registered in Ireland."
VITE_FOOTER_NOTE_2="Comany Number: 702423, Address: Suite 10628, 26/27 Upper Pembroke Street, Dublin 2, D02 X361, Ireland"

View File

@ -1,8 +1,10 @@
FROM node:bookworm as builder
FROM node:bookworm AS builder
ARG MODE=production
WORKDIR /src
COPY . .
RUN yarn && yarn build
RUN yarn && yarn build --mode $MODE
FROM nginx as runner
FROM nginx AS runner
WORKDIR /usr/share/nginx/html
COPY --from=builder /src/dist .
COPY --from=builder /src/dist .
COPY nginx.conf /etc/nginx/conf.d/default.conf

4
custom.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module "*.md" {
const value: string;
export default value;
}

10
nginx.conf Normal file
View 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;
}
}

View File

@ -10,15 +10,23 @@
"preview": "vite preview"
},
"dependencies": {
"@revolut/checkout": "^1.1.20",
"@scure/base": "^1.2.1",
"@snort/shared": "^1.0.17",
"@snort/system": "^1.5.7",
"@snort/system-react": "^1.5.7",
"@snort/system": "^1.6.1",
"@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",
"iso-3166-1": "^2.1.1",
"marked": "^15.0.7",
"qr-code-styling": "^1.8.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.0.1"
"react-router-dom": "^7.0.1",
"recharts": "^2.15.1"
},
"devDependencies": {
"@eslint/js": "^9.8.0",
@ -35,7 +43,7 @@
"tailwindcss": "^3.4.8",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.0",
"vite": "^5.4.0"
"vite": "^6.3.4"
},
"packageManager": "yarn@4.4.0"
}

View File

@ -9,12 +9,36 @@ export type ApiResponse<T> = ApiResponseBase & {
data: T;
};
export enum DiskType {
SSD = "ssd",
HDD = "hdd",
}
export enum DiskInterface {
SATA = "sata",
SCSI = "scsi",
PCIe = "pcie",
}
export interface AccountDetail {
email?: string;
contact_nip17: boolean;
contact_email: boolean;
country_code?: string;
name?: string;
address_1?: string;
address_2?: string;
city?: string;
state?: string;
postcode?: string;
tax_id?: string;
}
export interface VmCostPlan {
id: number;
name: string;
created: Date;
amount: number;
currency: "EUR" | "BTC";
currency: string;
interval_amount: number;
interval_type: string;
}
@ -22,25 +46,58 @@ export interface VmCostPlan {
export interface VmHostRegion {
id: number;
name: string;
enabled: boolean;
}
export interface VmCustomTemplateParams {
id: number;
name: string;
region: VmHostRegion;
max_cpu: number;
min_cpu: number;
min_memory: number;
max_memory: number;
disks: Array<VmCustomTemplateDiskParams>;
}
export interface VmCustomTemplateDiskParams {
min_disk: number;
max_disk: number;
disk_type: DiskType;
disk_interface: DiskInterface;
}
export interface VmCustomTemplateRequest {
pricing_id: number;
cpu: number;
memory: number;
disk: number;
disk_type: DiskType;
disk_interface: DiskInterface;
}
export interface VmCustomPrice {
currency: string;
amount: number;
}
export interface VmTemplateResponse {
templates: Array<VmTemplate>;
custom_template?: Array<VmCustomTemplateParams>;
}
export interface VmTemplate {
id: number;
pricing_id?: number;
name: string;
enabled: boolean;
created: Date;
expires?: Date;
cpu: number;
memory: number;
disk_size: number;
disk_type: string;
disk_interface: string;
cost_plan_id: number;
region_id: number;
cost_plan?: VmCostPlan;
region?: VmHostRegion;
disk_type: DiskType;
disk_interface: DiskInterface;
cost_plan: VmCostPlan;
region: VmHostRegion;
}
export interface VmStatus {
@ -57,26 +114,20 @@ export interface VmStatus {
export interface VmIpAssignment {
id: number;
ip: string;
gateway: string;
forward_dns?: string;
reverse_dns?: string;
}
export interface VmInstance {
id: number;
host_id: number;
user_id: number;
image_id: number;
template_id: number;
ssh_key_id: number;
created: string;
expires: string;
cpu: number;
memory: number;
disk_size: number;
disk_id: number;
status?: VmStatus;
template?: VmTemplate;
image?: VmOsImage;
ssh_key?: UserSshKey;
mac_address: string;
template: VmTemplate;
image: VmOsImage;
ssh_key: UserSshKey;
ip_assignments: Array<VmIpAssignment>;
}
@ -86,6 +137,7 @@ export interface VmOsImage {
flavour: string;
version: string;
release_date: string;
default_username?: string;
}
export interface UserSshKey {
@ -95,18 +147,84 @@ export interface UserSshKey {
export interface VmPayment {
id: string;
invoice: string;
created: string;
expires: string;
amount: number;
currency: string;
tax: number;
is_paid: boolean;
time: number;
data: {
lightning?: string;
revolut?: {
token: string;
};
};
}
export interface PatchVm {
ssh_key_id?: number;
reverse_dns?: string;
}
export interface TimeSeriesData {
timestamp: number;
cpu: number;
memory: number;
memory_size: number;
net_in: number;
net_out: number;
disk_write: number;
disk_read: number;
}
export interface PaymentMethod {
name: string;
currencies: Array<string>;
metadata?: Record<string, string>;
}
export interface NostrDomainsResponse {
domains: Array<NostrDomain>;
cname: string;
}
export interface NostrDomain {
id: number;
name: string;
enabled: boolean;
handles: number;
created: Date;
relays: Array<string>;
}
export interface NostrDomainHandle {
id: number;
domain_id: number;
handle: string;
created: Date;
pubkey: string;
}
export class LNVpsApi {
constructor(
readonly url: string,
readonly publisher: EventPublisher | undefined,
) { }
) {}
async getAccount() {
const { data } = await this.#handleResponse<ApiResponse<AccountDetail>>(
await this.#req("/api/v1/account", "GET"),
);
return data;
}
async updateAccount(acc: AccountDetail) {
const { data } = await this.#handleResponse<ApiResponse<void>>(
await this.#req("/api/v1/account", "PATCH", acc),
);
return data;
}
async listVms() {
const { data } = await this.#handleResponse<ApiResponse<Array<VmInstance>>>(
@ -122,13 +240,48 @@ export class LNVpsApi {
return data;
}
async listOffers() {
const { data } = await this.#handleResponse<ApiResponse<Array<VmTemplate>>>(
await this.#req("/api/v1/vm/templates", "GET"),
async getVmTimeSeries(id: number) {
const { data } = await this.#handleResponse<
ApiResponse<Array<TimeSeriesData>>
>(await this.#req(`/api/v1/vm/${id}/time-series`, "GET"));
return data;
}
async patchVm(id: number, req: PatchVm) {
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 reisntallVm(id: number) {
const { data } = await this.#handleResponse<ApiResponse<VmInstance>>(
await this.#req(`/api/v1/vm/${id}/re-install`, "PATCH"),
);
return data;
}
async listOffers() {
const { data } = await this.#handleResponse<
ApiResponse<VmTemplateResponse>
>(await this.#req("/api/v1/vm/templates", "GET"));
return data;
}
async listOsImages() {
const { data } = await this.#handleResponse<ApiResponse<Array<VmOsImage>>>(
await this.#req("/api/v1/image", "GET"),
@ -153,20 +306,50 @@ export class LNVpsApi {
return data;
}
async orderVm(template_id: number, image_id: number, ssh_key_id: number) {
async orderVm(
template_id: number,
image_id: number,
ssh_key_id: number,
ref_code?: string,
) {
const { data } = await this.#handleResponse<ApiResponse<VmInstance>>(
await this.#req("/api/v1/vm", "POST", {
template_id,
image_id,
ssh_key_id,
ref_code,
}),
);
return data;
}
async renewVm(vm_id: number) {
async customPrice(req: VmCustomTemplateRequest) {
const { data } = await this.#handleResponse<ApiResponse<VmCustomPrice>>(
await this.#req("/api/v1/vm/custom-template/price", "POST", req),
);
return data;
}
async orderCustom(
req: VmCustomTemplateRequest,
image_id: number,
ssh_key_id: number,
ref_code?: string,
) {
const { data } = await this.#handleResponse<ApiResponse<VmInstance>>(
await this.#req("/api/v1/vm/custom-template", "POST", {
...req,
image_id,
ssh_key_id,
ref_code,
}),
);
return data;
}
async renewVm(vm_id: number, method: string) {
const { data } = await this.#handleResponse<ApiResponse<VmPayment>>(
await this.#req(`/api/v1/vm/${vm_id}/renew`, "GET"),
await this.#req(`/api/v1/vm/${vm_id}/renew?method=${method}`, "GET"),
);
return data;
}
@ -178,6 +361,86 @@ export class LNVpsApi {
return data;
}
async invoiceLink(id: string) {
const u = `${this.url}/api/v1/payment/${id}/invoice`;
const auth = await this.#auth_event(u, "GET");
const auth_b64 = base64.encode(
new TextEncoder().encode(JSON.stringify(auth)),
);
return `${u}?auth=${auth_b64}`;
}
async listPayments(id: number) {
const { data } = await this.#handleResponse<ApiResponse<Array<VmPayment>>>(
await this.#req(`/api/v1/vm/${id}/payments`, "GET"),
);
return data;
}
async getPaymentMethods() {
const { data } = await this.#handleResponse<
ApiResponse<Array<PaymentMethod>>
>(await this.#req("/api/v1/payment/methods", "GET"));
return data;
}
async connect_terminal(id: number) {
const u = `${this.url}/api/v1/vm/${id}/console`;
const auth = await this.#auth_event(u, "GET");
const auth_b64 = base64.encode(
new TextEncoder().encode(JSON.stringify(auth)),
);
const ws = new WebSocket(`${u}?auth=${auth_b64}`);
return await new Promise<WebSocket>((resolve, reject) => {
ws.onopen = () => {
resolve(ws);
};
ws.onerror = (e) => {
reject(e);
};
});
}
async listDomains() {
const { data } = await this.#handleResponse<
ApiResponse<NostrDomainsResponse>
>(await this.#req("/api/v1/nostr/domain", "GET"));
return data;
}
async addDomain(domain: string) {
const { data } = await this.#handleResponse<ApiResponse<NostrDomain>>(
await this.#req("/api/v1/nostr/domain", "POST", { name: domain }),
);
return data;
}
async listDomainHandles(id: number) {
const { data } = await this.#handleResponse<
ApiResponse<Array<NostrDomainHandle>>
>(await this.#req(`/api/v1/nostr/domain/${id}/handle`, "GET"));
return data;
}
async addDomainHandle(domain: number, name: string, pubkey: string) {
const { data } = await this.#handleResponse<ApiResponse<NostrDomainHandle>>(
await this.#req(`/api/v1/nostr/domain/${domain}/handle`, "POST", {
name,
pubkey,
}),
);
return data;
}
async deleteDomainHandle(domain_id: number, handle_id: number) {
await this.#handleResponse<ApiResponse<void>>(
await this.#req(
`/api/v1/nostr/domain/${domain_id}/handle/${handle_id}`,
"DELETE",
),
);
}
async #handleResponse<T extends ApiResponseBase>(rsp: Response) {
if (rsp.ok) {
return (await rsp.json()) as T;
@ -192,21 +455,29 @@ export class LNVpsApi {
}
}
async #req(path: string, method: "GET" | "POST" | "DELETE", body?: object) {
const auth = async (url: string, method: string) => {
const auth = await this.publisher?.generic((eb) => {
return eb
.kind(EventKind.HttpAuthentication)
.tag(["u", url])
.tag(["method", method]);
});
if (auth) {
return `Nostr ${base64.encode(
new TextEncoder().encode(JSON.stringify(auth)),
)}`;
}
};
async #auth_event(url: string, method: string) {
return await this.publisher?.generic((eb) => {
return eb
.kind(EventKind.HttpAuthentication)
.tag(["u", url])
.tag(["method", method]);
});
}
async #auth(url: string, method: string) {
const auth = await this.#auth_event(url, method);
if (auth) {
return `Nostr ${base64.encode(
new TextEncoder().encode(JSON.stringify(auth)),
)}`;
}
}
async #req(
path: string,
method: "GET" | "POST" | "DELETE" | "PUT" | "PATCH",
body?: object,
) {
const u = `${this.url}${path}`;
return await fetch(u, {
method,
@ -214,7 +485,7 @@ export class LNVpsApi {
headers: {
accept: "application/json",
"content-type": "application/json",
authorization: (await auth(u, method)) ?? "",
authorization: (await this.#auth(u, method)) ?? "",
},
});
}

71
src/blossom.ts Normal file
View 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),
},
});
}
}

View File

@ -0,0 +1,63 @@
import { useState, useEffect } from "react";
import { NostrDomainsResponse } from "../api";
import useLogin from "../hooks/login";
import { AsyncButton } from "./button";
import Modal from "./modal";
import { NostrDomainRow } from "./nostr-domain-row";
export function AccountNostrDomains() {
const login = useLogin();
const [domains, setDomains] = useState<NostrDomainsResponse>();
const [addDomain, setAddDomain] = useState(false);
const [newDomain, setNewDomain] = useState<string>();
useEffect(() => {
if (login?.api) {
login.api.listDomains().then(setDomains);
}
}, [login]);
return (
<>
<div className="flex flex-col gap-2">
<h3>Nostr Domains</h3>
<small>
Free NIP-05 hosting, add a CNAME entry pointing to
<code className="bg-neutral-900 px-2 py-1 rounded-full select-all">
{domains?.cname}
</code>
</small>
{domains?.domains.map((d) => (
<NostrDomainRow domain={d} canEdit={true} />
))}
</div>
<AsyncButton onClick={() => setAddDomain(true)}>Add Domain</AsyncButton>
{addDomain && (
<Modal id="add-nostr-domain" onClose={() => setAddDomain(false)}>
<div className="flex flex-col gap-4">
<div className="text-xl">Add Nostr Domain</div>
<input
type="text"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
placeholder="example.com"
/>
<AsyncButton
onClick={async () => {
if (newDomain && newDomain.length > 4 && login?.api) {
await login.api.addDomain(newDomain);
const doms = await login.api.listDomains();
setDomains(doms);
setNewDomain(undefined);
setAddDomain(false);
}
}}
>
Add
</AsyncButton>
</div>
</Modal>
)}
</>
);
}

View File

@ -0,0 +1,27 @@
import classNames from "classnames";
import { ReactNode } from "react";
export function FilterButton({
children,
onClick,
active,
}: {
children: ReactNode;
onClick?: () => Promise<void> | void;
active?: boolean;
}) {
return (
<div
className={classNames(
"rounded-full outline outline-1 px-4 py-1 cursor-pointer select-none",
{
"bg-neutral-800 outline-neutral-300": active,
"bg-neutral-900 outline-neutral-800 text-neutral-500": !active,
},
)}
onClick={onClick}
>
{children}
</div>
);
}

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-2 px-3 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

@ -1,20 +1,80 @@
import { VmCostPlan } from "../api";
import useLogin from "../hooks/login";
export default function CostLabel({ cost }: { cost: VmCostPlan }) {
function intervalName(n: string) {
switch (n) {
case "day":
return "Day";
case "month":
return "Month";
case "year":
return "Year";
interface Price {
currency: string;
amount: number;
}
type Cost = Price & { interval_type?: string };
export default function CostLabel({
cost,
}: {
cost: Cost & { other_price?: Array<Price> };
}) {
const login = useLogin();
if (cost.currency === login?.currency) {
return <CostAmount cost={cost} converted={false} />;
} else {
const converted_price = cost.other_price?.find(
(p) => p.currency === login?.currency,
);
if (converted_price) {
return (
<div>
<CostAmount
cost={{
...converted_price,
interval_type: cost.interval_type,
}}
converted={true}
/>
<CostAmount
cost={cost}
converted={false}
className="text-sm text-neutral-400"
/>
</div>
);
} else {
return <CostAmount cost={cost} converted={false} />;
}
}
}
function intervalName(n: string) {
switch (n) {
case "day":
return "Day";
case "month":
return "Month";
case "year":
return "Year";
}
}
export function CostAmount({
cost,
converted,
className,
}: {
cost: Cost;
converted: boolean;
className?: string;
}) {
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: cost.currency,
trailingZeroDisplay: "stripIfInteger",
});
return (
<>
{cost.amount} {cost.currency}/{intervalName(cost.interval_type)}
</>
<div className={className}>
{converted && "~"}
{cost.currency !== "BTC"
? formatter.format(cost.amount)
: Math.floor(cost.amount * 1e8).toLocaleString()}
{cost.currency === "BTC" && " sats"}
{cost.interval_type && <>/{intervalName(cost.interval_type)}</>}
</div>
);
}

View File

@ -1,6 +1,7 @@
import classNames from "classnames";
import { MouseEventHandler } from "react";
import Icons from "../icons.svg?url";
import Icons from "../icons.svg?no-inline";
type Props = {
name: string;
@ -17,7 +18,7 @@ export function Icon(props: Props) {
<svg
width={size}
height={size}
className={props.className}
className={classNames(props.className, "cursor-pointer")}
onClick={props.onClick}
>
<use href={href} />

View File

@ -0,0 +1,24 @@
import { EventKind, RequestBuilder } from "@snort/system";
import { NostrProfile } from "../const";
import { useRequestBuilder } from "@snort/system-react";
import { NewLink } from "./news-link";
export function LatestNews() {
const req = new RequestBuilder("latest-news");
req
.withFilter()
.kinds([EventKind.LongFormTextNote])
.authors([NostrProfile.id])
.limit(1);
const posts = useRequestBuilder(req);
if (posts.length > 0) {
return (
<div className="flex flex-col gap-2">
<div className="text-xl">Latest News</div>
<NewLink ev={posts[0]} />
</div>
);
}
}

View File

@ -1,27 +1,24 @@
import { SnortContext } from "@snort/system-react";
import { useContext } from "react";
import { AsyncButton } from "./button";
import { loginNip7 } from "../login";
import useLogin from "../hooks/login";
import Profile from "./profile";
import { NostrLink } from "@snort/system";
import { Link } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
export default function LoginButton() {
const system = useContext(SnortContext);
const login = useLogin();
const navigate = useNavigate();
return !login ? (
<AsyncButton
onClick={async () => {
await loginNip7(system);
navigate("/login");
}}
>
Sign In
</AsyncButton>
) : (
<Link to="/account">
<Profile link={NostrLink.publicKey(login.pubkey)} />
<Profile link={NostrLink.publicKey(login.publicKey)} />
</Link>
);
}

195
src/components/markdown.tsx Normal file
View File

@ -0,0 +1,195 @@
import { ReactNode, forwardRef, useMemo } from "react";
import { Token, Tokens, marked } from "marked";
import { Link } from "react-router-dom";
interface MarkdownProps {
content: string;
}
const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
(props: MarkdownProps, ref) => {
let ctr = 0;
function renderToken(t: Token): ReactNode {
try {
switch (t.type) {
case "paragraph": {
return (
<p key={ctr++} className="py-2">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</p>
);
}
case "image": {
return <img key={ctr++} src={t.href} />;
}
case "heading": {
switch (t.depth) {
case 1:
return (
<h1 key={ctr++} className="my-6 text-2xl">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h1>
);
case 2:
return (
<h2 key={ctr++} className="my-5 text-xl">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h2>
);
case 3:
return (
<h3 key={ctr++} className="my-4 text-lg">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h3>
);
case 4:
return (
<h4 key={ctr++} className="my-3 text-md">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h4>
);
case 5:
return (
<h5 key={ctr++} className="my-2">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h5>
);
case 6:
return (
<h6 key={ctr++} className="my-2">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h6>
);
}
throw new Error("Invalid heading");
}
case "codespan": {
return (
<code key={ctr++} className="bg-neutral-900 px-2">
{t.raw.substring(1, t.raw.length - 1)}
</code>
);
}
case "code": {
return <pre key={ctr++}>{t.raw}</pre>;
}
case "br": {
return <br key={ctr++} />;
}
case "hr": {
return <hr key={ctr++} />;
}
case "strong": {
return (
<b key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</b>
);
}
case "blockquote": {
return (
<blockquote
key={ctr++}
className="outline-l-neutral-900 outline text-neutral-300 p-3"
>
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</blockquote>
);
}
case "link": {
return (
<Link to={t.href} key={ctr++} className="underline">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</Link>
);
}
case "list": {
if (t.ordered) {
return (
<ol key={ctr++} className="list-decimal list-outside">
{t.items.map(renderToken)}
</ol>
);
} else {
return (
<ul key={ctr++} className="list-disc list-outside">
{t.items.map(renderToken)}
</ul>
);
}
}
case "list_item": {
return (
<li key={ctr++}>
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</li>
);
}
case "em": {
return (
<em key={ctr++}>
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</em>
);
}
case "del": {
return (
<s key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</s>
);
}
case "table": {
return (
<table className="table-auto border-collapse" key={ctr++}>
<thead>
<tr>
{(t.header as Tokens.TableCell[]).map((v) => (
<th className="border" key={ctr++}>
{v.tokens ? v.tokens.map(renderToken) : v.text}
</th>
))}
</tr>
</thead>
<tbody>
{(t.rows as Tokens.TableCell[][]).map((v) => (
<tr key={ctr++}>
{v.map((d, d_key) => (
<td className="border px-2 py-1" key={d_key}>
{d.tokens ? d.tokens.map(renderToken) : d.text}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
case "text": {
if ("tokens" in t) {
return (t.tokens as Array<Token>).map(renderToken);
}
return t.raw;
}
case "space": {
return " ";
}
default: {
console.debug(`Unknown token ${t.type}`);
}
}
} catch (e) {
console.error(e);
}
}
const parsed = useMemo(() => {
return marked.lexer(props.content);
}, [props.content]);
return (
<div className="leading-8 text-pretty break-words" ref={ref}>
{parsed
.filter((a) => a.type !== "footnote" && a.type !== "footnotes")
.map((a) => renderToken(a))}
</div>
);
},
);
export default Markdown;

View File

@ -51,7 +51,7 @@ export default function Modal(props: ModalProps) {
className={
props.bodyClassName ??
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-800 p-8 transition max-xl:rounded-t-3xl lg:rounded-3xl max-xl:mt-auto lg:my-auto max-lg:w-full",
{
"max-xl:-translate-y-[calc(100vh-100dvh)]": props.ready ?? true,
"max-xl:translate-y-[50vh]": !(props.ready ?? true),

View File

@ -0,0 +1,26 @@
import { NostrEvent, NostrLink } from "@snort/system";
import { Link } from "react-router-dom";
export function NewLink({ ev }: { ev: NostrEvent }) {
const link = NostrLink.fromEvent(ev);
const title = ev.tags.find((a) => a[0] == "title")?.[1];
const posted = Number(
ev.tags.find((a) => a[0] == "published_at")?.[1] ?? ev.created_at,
);
const slug = title
?.toLocaleLowerCase()
.replace(/[:/]/g, "")
.trimStart()
.trimEnd()
.replace(/ /g, "-");
return (
<Link to={`/news/${slug}`} state={ev} key={link.tagKey}>
<div className="flex flex-col rounded-xl bg-neutral-900 px-3 py-4">
<div className="text-xl flex items-center justify-between">
<div>{title}</div>
<div>{new Date(posted * 1000).toDateString()}</div>
</div>
</div>
</Link>
);
}

View File

@ -0,0 +1,40 @@
import { useNavigate } from "react-router-dom";
import { NostrDomain } from "../api";
import { AsyncButton } from "./button";
import { Icon } from "./icon";
export function NostrDomainRow({
domain,
canEdit,
}: {
domain: NostrDomain;
canEdit?: boolean;
}) {
const navigate = useNavigate();
return (
<div
className="bg-neutral-900 rounded-xl px-2 py-3 flex items-center justify-between"
key={domain.id}
>
<div className="flex flex-col gap-2">
<div>{domain.name}</div>
<div className="flex gap-2 items-center text-neutral-400 text-sm">
<div>{domain.handles} handles</div>
{!domain.enabled && <div className="text-red-500">Inactive</div>}
</div>
</div>
{canEdit && (
<AsyncButton
className="bg-neutral-700 hover:bg-neutral-600"
onClick={() =>
navigate("/account/nostr-domain", {
state: domain,
})
}
>
<Icon name={"pencil"} size={30} />
</AsyncButton>
)}
</div>
);
}

View File

@ -1,4 +1,3 @@
import { ReactNode } from "react";
import { VmTemplate } from "../api";
import useLogin from "../hooks/login";
import { AsyncButton } from "./button";
@ -18,16 +17,15 @@ export default function VpsPayButton({ spec }: { spec: VmTemplate }) {
"w-full text-center text-lg uppercase rounded-xl py-3 font-bold cursor-pointer select-none";
const navigte = useNavigate();
function placeholder(inner: ReactNode) {
return <div className={`${classNames} bg-red-900`}>{inner}</div>;
}
if (!spec.enabled) {
return placeholder("Unavailable");
}
if (!login) {
return placeholder("Please Login");
return (
<AsyncButton
className={`${classNames} bg-red-900`}
onClick={() => navigte("/login")}
>
Login To Order
</AsyncButton>
);
}
return (
<AsyncButton

View File

@ -2,19 +2,26 @@ import { hexToBech32 } from "@snort/shared";
import { NostrLink } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
export default function Profile({ link }: { link: NostrLink }) {
export default function Profile({
link,
withName,
}: {
link: NostrLink;
withName?: boolean;
}) {
const profile = useUserProfile(link.id);
const name = profile?.display_name ?? profile?.name ?? "";
return (
<div className="flex gap-2 items-center">
<img
src={profile?.picture}
className="w-12 h-12 rounded-full bg-neutral-800 object-cover object-center"
/>
<div>
{profile?.display_name ??
profile?.name ??
hexToBech32("npub", link.id).slice(0, 12)}
</div>
{(withName ?? true) && (
<div>
{name.length > 0 ? name : hexToBech32("npub", link.id).slice(0, 12)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,69 @@
import RevolutCheckout, { Mode } from "@revolut/checkout";
import { useEffect, useRef } from "react";
import { VmCostPlan } from "../api";
interface RevolutProps {
amount:
| VmCostPlan
| {
amount: number;
currency: string;
tax?: number;
};
pubkey: string;
loadOrder: () => Promise<string>;
onPaid: () => void;
onCancel?: () => void;
mode?: string;
}
export function RevolutPayWidget({
pubkey,
loadOrder,
amount,
onPaid,
onCancel,
mode,
}: RevolutProps) {
const ref = useRef<HTMLDivElement | null>(null);
async function load(pubkey: string, ref: HTMLDivElement) {
const { revolutPay } = await RevolutCheckout.payments({
locale: "auto",
mode: (mode ?? "prod") as Mode,
publicToken: pubkey,
});
ref.innerHTML = "";
const payload = {
currency: amount.currency,
totalAmount: amount.amount * 100,
createOrder: async () => {
const id = await loadOrder();
return {
publicId: id,
};
},
buttonStyle: {
cashback: false,
},
};
console.debug("Revolut order: ", payload);
revolutPay.mount(ref, payload);
revolutPay.on("payment", (payload) => {
console.debug(payload);
if (payload.type === "success") {
onPaid();
}
if (payload.type === "cancel") {
onCancel?.();
}
});
}
useEffect(() => {
if (ref.current) {
load(pubkey, ref.current);
}
}, [pubkey, ref]);
return <div ref={ref}></div>;
}

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

@ -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>
);
}

View File

@ -1,36 +1,64 @@
import { VmInstance } from "../api";
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;
if (!login?.api) return;
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
title={state === "running" ? "Stop VM" : "Start VM"}
onClick={async (e) => {
e.stopPropagation();
if (state === "running") {
await login?.api.stopVm(vm.id);
} else {
await login?.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}
onClick={e => {
onClick={(e) => {
e.stopPropagation();
}}
/>
<Icon
name="refresh-1"
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
size={40}
onClick={e => {
/>*/}
<AsyncButton
title="Reinstall"
onClick={async (e) => {
e.stopPropagation();
if (
confirm(
"Are you sure you want to re-install your vm?\nTHIS WILL DELETE ALL DATA!!",
)
) {
await login?.api.reisntallVm(vm.id);
onReload?.();
}
}}
/>
className="bg-neutral-700 hover:bg-neutral-600"
>
<Icon name="refresh-1" size={30} />
</AsyncButton>
</div>
</div>
);

View File

@ -5,8 +5,8 @@ import VpsPayButton from "./pay-button";
export default function VpsCard({ spec }: { spec: VmTemplate }) {
return (
<div className="rounded-xl border border-neutral-600 px-3 py-2">
<h2>{spec.name}</h2>
<div className="rounded-xl border border-neutral-600 px-3 py-2 flex flex-col gap-2">
<div className="text-xl">{spec.name}</div>
<ul>
<li>CPU: {spec.cpu}vCPU</li>
<li>
@ -17,7 +17,9 @@ export default function VpsCard({ spec }: { spec: VmTemplate }) {
</li>
<li>Location: {spec.region?.name}</li>
</ul>
<h2>{spec.cost_plan && <CostLabel cost={spec.cost_plan} />}</h2>
<div className="text-lg">
{spec.cost_plan && <CostLabel cost={spec.cost_plan} />}
</div>
<VpsPayButton spec={spec} />
</div>
);

View File

@ -0,0 +1,137 @@
import { useEffect, useState } from "react";
import {
DiskInterface,
DiskType,
LNVpsApi,
VmCustomPrice,
VmCustomTemplateParams,
} from "../api";
import { ApiUrl, GiB } from "../const";
import CostLabel from "./cost";
import VpsPayButton from "./pay-button";
import { FilterButton } from "./button-filter";
export function VpsCustomOrder({
templates,
}: {
templates: Array<VmCustomTemplateParams>;
}) {
const [region] = useState(templates.at(0)?.region.id);
const params = templates.find((t) => t.region.id == region) ?? templates[0];
const [cpu, setCpu] = useState(params.min_cpu ?? 1);
const [diskType, setDiskType] = useState(params.disks.at(0));
const [ram, setRam] = useState(Math.floor((params.min_memory ?? GiB) / GiB));
const [disk, setDisk] = useState(
Math.floor((diskType?.min_disk ?? GiB) / GiB),
);
const [price, setPrice] = useState<VmCustomPrice>();
const cost_plan = {
id: 0,
name: "custom",
amount: price?.amount ?? 0,
currency: price?.currency ?? "",
interval_amount: 1,
interval_type: "month",
};
useEffect(() => {
const t = setTimeout(() => {
const api = new LNVpsApi(ApiUrl, undefined);
api
.customPrice({
pricing_id: params.id,
cpu,
memory: ram * GiB,
disk: disk * GiB,
disk_type: diskType?.disk_type ?? DiskType.SSD,
disk_interface: diskType?.disk_interface ?? DiskInterface.PCIe,
})
.then(setPrice);
}, 500);
return () => clearTimeout(t);
}, [region, cpu, ram, disk, diskType, params]);
if (templates.length == 0) return;
return (
<div className="flex flex-col gap-4 bg-neutral-900 rounded-xl px-4 py-6">
<div className="text-lg">Custom VPS Order</div>
{params.disks.length > 1 && (
<div className="flex gap-2">
{params.disks.map((d) => (
<FilterButton
active={diskType?.disk_type === d.disk_type}
onClick={() => setDiskType(d)}
>
{d.disk_type.toUpperCase()}
</FilterButton>
))}
</div>
)}
<div className="flex items-center gap-4">
<div className="min-w-[100px]">{cpu} CPU</div>
<input
type="range"
value={cpu}
onChange={(e) => setCpu(e.target.valueAsNumber)}
min={params.min_cpu}
max={params.max_cpu}
step={1}
className="grow"
/>
</div>
<div className="flex items-center gap-4">
<div className="min-w-[100px]">{ram.toString()} GB RAM</div>
<input
type="range"
value={ram}
onChange={(e) => setRam(e.target.valueAsNumber)}
min={Math.floor(params.min_memory / GiB)}
max={Math.floor(params.max_memory / GiB)}
step={1}
className="grow"
/>
</div>
<div className="flex items-center gap-4">
<div className="min-w-[100px]">
{disk.toString()} GB {diskType?.disk_type.toLocaleUpperCase()}
</div>
<input
type="range"
value={disk}
onChange={(e) => setDisk(e.target.valueAsNumber)}
min={Math.floor((diskType?.min_disk ?? 0) / GiB)}
max={Math.floor((diskType?.max_disk ?? 0) / GiB)}
step={1}
className="grow"
/>
</div>
{price && (
<div className="flex items-center justify-between">
<div className="text-xl flex-1">
<CostLabel cost={cost_plan} />
</div>
<div className="flex-1">
<VpsPayButton
spec={{
id: 0,
pricing_id: params.id,
cpu,
name: "Custom",
memory: ram * GiB,
disk_size: disk * GiB,
disk_type: diskType?.disk_type ?? DiskType.SSD,
disk_interface: diskType?.disk_interface ?? DiskInterface.PCIe,
created: new Date(),
region: params.region,
cost_plan,
}}
/>
</div>
</div>
)}
</div>
);
}

View File

@ -4,16 +4,28 @@ import OsImageName from "./os-image-name";
import VpsResources from "./vps-resources";
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 isExpired = expires <= new Date();
const navigate = useNavigate();
return (
<div className="flex justify-between items-center rounded-xl bg-neutral-900 px-3 py-2 cursor-pointer hover:bg-neutral-800"
onClick={() => navigate("/vm", {
state: vm
})}>
<div
className="flex justify-between items-center rounded-xl bg-neutral-900 px-3 py-2 cursor-pointer hover:bg-neutral-800"
onClick={() =>
navigate("/vm", {
state: vm,
})
}
>
<div className="flex flex-col gap-2">
<div>
<span className="text-sm text-neutral-400">#{vm.id}</span>
@ -26,16 +38,23 @@ export default function VpsInstanceRow({ vm, actions }: { vm: VmInstance, action
</div>
<VpsResources vm={vm} />
</div>
{(actions ?? true) && <div className="flex gap-2 items-center">
<div className="flex gap-2 items-center">
{isExpired && (
<>
<Link to="/vm/renew" className="text-red-500 text-sm" state={vm}>
<Link
to="/vm/billing/renew"
className="text-red-500 text-sm"
state={vm}
onClick={(e) => e.stopPropagation()}
>
Expired
</Link>
</>
)}
{!isExpired && <VmActions vm={vm} />}
</div>}
{!isExpired && (actions ?? true) && (
<VmActions vm={vm} onReload={onReload} />
)}
</div>
</div>
);
}

View File

@ -2,8 +2,6 @@ import { useEffect } from "react";
import { LNVpsApi, VmPayment } from "../api";
import QrCode from "./qr";
import useLogin from "../hooks/login";
import { ApiUrl } from "../const";
import { EventPublisher } from "@snort/system";
export default function VpsPayment({
payment,
@ -13,7 +11,8 @@ export default function VpsPayment({
onPaid?: () => void;
}) {
const login = useLogin();
const ln = `lightning:${payment.invoice}`;
const invoice = payment.data.lightning;
const ln = `lightning:${invoice}`;
async function checkPayment(api: LNVpsApi) {
try {
@ -29,18 +28,15 @@ export default function VpsPayment({
}
useEffect(() => {
if (!login?.signer) return;
const api = new LNVpsApi(
ApiUrl,
new EventPublisher(login.signer, login.pubkey),
);
if (!login?.api) return;
const tx = setInterval(async () => {
if (await checkPayment(api)) {
if (await checkPayment(login.api)) {
clearInterval(tx);
}
}, 2_000);
return () => clearInterval(tx);
}, [login]);
}, [login, onPaid]);
return (
<div className="flex flex-col gap-4 rounded-xl p-3 bg-neutral-900 items-center">
@ -52,7 +48,19 @@ export default function VpsPayment({
avatar="/logo.jpg"
className="cursor-pointer rounded-xl overflow-hidden"
/>
{(payment.amount / 1000).toLocaleString()} sats
<div className="flex flex-col items-center">
<div>
{((payment.amount + payment.tax) / 1000).toLocaleString()} sats
</div>
{payment.tax > 0 && (
<div className="text-xs">
including {(payment.tax / 1000).toLocaleString()} sats tax
</div>
)}
</div>
<div className="monospace select-all break-all text-center text-sm">
{invoice}
</div>
</div>
);
}

View File

@ -2,16 +2,16 @@ import { VmInstance, VmTemplate } from "../api";
import BytesSize from "./bytes";
export default function VpsResources({ vm }: { vm: VmInstance | VmTemplate }) {
const diskType = "template_id" in vm ? vm.template?.disk_type : vm.disk_type;
const region =
"region_id" in vm ? vm.region?.name : vm.template?.region?.name;
const diskType = "template" in vm ? vm.template?.disk_type : vm.disk_type;
const region = "region" in vm ? vm.region.name : vm.template?.region?.name;
const status = "status" in vm ? vm.status : undefined;
const template = "template" in vm ? vm.template : (vm as VmTemplate);
return (
<>
<div className="text-xs text-neutral-400">
{vm.cpu} vCPU, <BytesSize value={vm.memory} /> RAM,{" "}
<BytesSize value={vm.disk_size} /> {diskType?.toUpperCase()},{" "}
{region && <>Location: {region}</>}
{template?.cpu} vCPU, <BytesSize value={template?.memory ?? 0} /> RAM,{" "}
<BytesSize value={template?.disk_size ?? 0} /> {diskType?.toUpperCase()}
, {region && <>Location: {region}</>}
</div>
{status && status.state === "running" && (
<div className="text-sm text-neutral-200">

View File

@ -12,8 +12,7 @@ export const GB = KB * 1000;
export const TB = GB * 1000;
export const PB = TB * 1000;
export const ApiUrl = "http://localhost:8000";
//export const ApiUrl = "https://api.lnvps.net";
export const ApiUrl = import.meta.env.VITE_API_URL;
export const NostrProfile = new NostrLink(
NostrPrefix.Profile,

View File

@ -1,9 +1,29 @@
import { useSyncExternalStore } from "react";
import { Login } from "../login";
import { useContext, useMemo, useSyncExternalStore } from "react";
import { LoginSession, LoginState } from "../login";
import { SnortContext } from "@snort/system-react";
import { LNVpsApi } from "../api";
import { ApiUrl } from "../const";
export default function useLogin() {
return useSyncExternalStore(
(c) => Login.hook(c),
() => Login.snapshot(),
const session = useSyncExternalStore(
(c) => LoginState.hook(c),
() => LoginState.snapshot(),
);
const system = useContext(SnortContext);
return useMemo(
() =>
session
? {
type: session.type,
publicKey: session.publicKey,
system,
currency: session.currency,
api: new LNVpsApi(ApiUrl, LoginState.getSigner()),
update: (fx: (ses: LoginSession) => void) =>
LoginState.updateSession(fx),
logout: () => LoginState.logout(),
}
: undefined,
[session, system],
);
}

View File

@ -1,20 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg">
<defs>
<svg id="start" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<symbol id="start" 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="M13.5 3C13.5 2.17157 12.8284 1.5 12 1.5C11.1716 1.5 10.5 2.17157 10.5 3V13C10.5 13.8284 11.1716 14.5 12 14.5C12.8284 14.5 13.5 13.8284 13.5 13V3ZM7.85385 5.7491C8.54371 5.29043 8.73113 4.35936 8.27245 3.6695C7.81378 2.97963 6.8827 2.79222 6.19284 3.2509C3.36739 5.12948 1.5 8.34635 1.5 12C1.5 17.799 6.20101 22.5 12 22.5C17.799 22.5 22.5 17.799 22.5 12C22.5 8.34635 20.6326 5.12948 17.8072 3.2509C17.1173 2.79222 16.1862 2.97963 15.7275 3.6695C15.2689 4.35936 15.4563 5.29043 16.1461 5.7491C18.1708 7.09528 19.5 9.39275 19.5 12C19.5 16.1422 16.1421 19.5 12 19.5C7.85786 19.5 4.5 16.1422 4.5 12C4.5 9.39275 5.82917 7.09528 7.85385 5.7491Z" fill="#F7F9FC"/>
</svg>
<svg id="stop" viewBox="0 0 24 24" fill="none">
</symbol>
<symbol id="stop" viewBox="0 0 24 24" fill="none">
<path d="M24 0V24H0V0H24Z" fill="white" fill-opacity="0.01"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 6C4 4.89543 4.89543 4 6 4H18C19.1046 4 20 4.89543 20 6V18C20 19.1046 19.1046 20 18 20H6C4.89543 20 4 19.1046 4 18V6Z" fill="white"/>
</svg>
<svg id="delete" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
</symbol>
<symbol id="delete" 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="M9 3C9 2.44772 9.44772 2 10 2H14C14.5523 2 15 2.44772 15 3C15 3.55228 14.5523 4 14 4H10C9.44772 4 9 3.55228 9 3ZM5.92032 5H4C3.44772 5 3 5.44772 3 6C3 6.55228 3.44772 7 4 7H4.99745L5.9362 20.1425C6.01096 21.1891 6.88184 22 7.93112 22H16.0689C17.1182 22 17.989 21.1891 18.0638 20.1425L19.0025 7H20C20.5523 7 21 6.55228 21 6C21 5.44772 20.5523 5 20 5H18.0797C18.0735 4.99994 18.0673 4.99994 18.0611 5H5.93889C5.93271 4.99994 5.92652 4.99994 5.92032 5Z" fill="#F7F9FC"/>
</svg>
<svg id="refresh-1" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
</symbol>
<symbol id="refresh-1" 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="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>
</symbol>
<symbol 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"/>
</symbol>
<symbol id="printer" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-printer-icon lucide-printer"><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6"/><rect x="6" y="14" width="12" height="8" rx="1"/></symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -4,6 +4,7 @@
:root {
font-family: "Source Code Pro", monospace;
font-size: 15px;
@apply bg-black text-white;
}
@ -35,3 +36,13 @@ a:hover {
hr {
@apply border-neutral-800;
}
input,
textarea,
select {
@apply border-none rounded-xl bg-neutral-900 p-2;
}
input:disabled {
@apply text-neutral-200/50;
}

View File

@ -1,38 +1,123 @@
import { ExternalStore } from "@snort/shared";
import {
EventSigner,
EventPublisher,
Nip46Signer,
Nip7Signer,
SystemInterface,
UserState,
PrivateKeySigner,
} from "@snort/system";
class LoginShell extends ExternalStore<UserState<void> | undefined> {
#state?: UserState<void>;
export interface LoginSession {
type: "nip7" | "nsec" | "nip46";
publicKey: string;
privateKey?: string;
bunker?: string;
currency: string;
}
class LoginStore extends ExternalStore<LoginSession | undefined> {
#session?: LoginSession;
#signer?: EventPublisher;
async login(signer: EventSigner, system: SystemInterface) {
if (this.#state !== undefined) {
throw new Error("Already logged in");
constructor() {
super();
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() {
return this.#state;
return this.#session ? { ...this.#session } : undefined;
}
logout() {
this.#session = undefined;
this.#signer = undefined;
this.#save();
}
login(pubkey: string, type: LoginSession["type"] = "nip7") {
this.#session = {
type: type ?? "nip7",
publicKey: pubkey,
currency: "EUR",
};
this.#save();
}
loginPrivateKey(key: string) {
const s = new PrivateKeySigner(key);
this.#session = {
type: "nsec",
publicKey: s.getPubKey(),
privateKey: key,
currency: "EUR",
};
this.#save();
}
loginBunker(url: string, localKey: string, remotePubkey: string) {
this.#session = {
type: "nip46",
publicKey: remotePubkey,
privateKey: localKey,
bunker: url,
currency: "EUR",
};
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!";
}
updateSession(fx: (s: LoginSession) => void) {
if (this.#session) {
fx(this.#session);
this.#save();
}
}
#save() {
if (this.#session) {
window.localStorage.setItem("session", JSON.stringify(this.#session));
} else {
window.localStorage.removeItem("session");
}
this.notifyChange();
}
}
export const Login = new LoginShell();
export async function loginNip7(system: SystemInterface) {
const signer = new Nip7Signer();
const pubkey = await signer.getPubKey();
if (pubkey) {
await Login.login(signer, system);
} else {
throw new Error("No nostr extension found");
}
}
export const LoginState = new LoginStore();

View File

@ -9,6 +9,16 @@ import HomePage from "./pages/home.tsx";
import OrderPage from "./pages/order.tsx";
import VmPage from "./pages/vm.tsx";
import AccountPage from "./pages/account.tsx";
import SignUpPage from "./pages/sign-up.tsx";
import { TosPage } from "./pages/terms.tsx";
import { StatusPage } from "./pages/status.tsx";
import { AccountSettings } from "./pages/account-settings.tsx";
import { VmBillingPage } from "./pages/vm-billing.tsx";
import { VmGraphsPage } from "./pages/vm-graphs.tsx";
import { NewsPage } from "./pages/news.tsx";
import { NewsPost } from "./pages/news-post.tsx";
import { VmConsolePage } from "./pages/vm-console.tsx";
import { AccountNostrDomainPage } from "./pages/account-domain.tsx";
const system = new NostrSystem({
automaticOutboxModel: false,
@ -29,18 +39,58 @@ const router = createBrowserRouter([
path: "/",
element: <HomePage />,
},
{
path: "/login",
element: <SignUpPage />,
},
{
path: "/account",
element: <AccountPage />,
},
{
path: "/account/settings",
element: <AccountSettings />,
},
{
path: "/account/nostr-domain",
element: <AccountNostrDomainPage />,
},
{
path: "/order",
element: <OrderPage />,
},
{
path: "/vm/:action?",
path: "/vm",
element: <VmPage />,
},
{
path: "/vm/billing/:action?",
element: <VmBillingPage />,
},
{
path: "/vm/graphs",
element: <VmGraphsPage />,
},
{
path: "/vm/console",
element: <VmConsolePage />,
},
{
path: "/tos",
element: <TosPage />,
},
{
path: "/status",
element: <StatusPage />,
},
{
path: "/news",
element: <NewsPage />,
},
{
path: "/news/:id",
element: <NewsPost />,
},
],
},
]);

View File

@ -0,0 +1,126 @@
import { Link, useLocation } from "react-router-dom";
import { NostrDomain, NostrDomainHandle } from "../api";
import { useEffect, useState } from "react";
import useLogin from "../hooks/login";
import { AsyncButton } from "../components/button";
import { NostrDomainRow } from "../components/nostr-domain-row";
import Modal from "../components/modal";
import { tryParseNostrLink } from "@snort/system";
import { hexToBech32 } from "@snort/shared";
import { Icon } from "../components/icon";
export function AccountNostrDomainPage() {
const { state } = useLocation();
const login = useLogin();
const [handles, setHandles] = useState<Array<NostrDomainHandle>>();
const [addHandle, setAddHandle] = useState(false);
const [newHandle, setNewHandle] = useState<string>();
const [newHandlePubkey, setNewHandlePubkey] = useState<string>();
const [newHandleError, setNewHandleError] = useState<string>();
const domain = state as NostrDomain;
useEffect(() => {
if (login?.api) {
login.api.listDomainHandles(domain.id).then(setHandles);
}
}, [login]);
return (
<div className="flex flex-col gap-4">
<Link to={"/account"}>&lt; Back</Link>
<NostrDomainRow domain={domain} />
<div className="text-xl">Handles</div>
<div className="flex flex-col gap-1">
{handles !== undefined && handles.length === 0 && (
<div className="text-red-500 text-sm">No Registerd Handles</div>
)}
{handles?.map((a) => (
<div
className="flex items-center p-2 rounded-xl bg-neutral-900 justify-between"
key={a.id}
>
<div className="flex flex-col gap-2">
<div>{a.handle}</div>
<div className="text-neutral-500 text-sm">
{hexToBech32("npub", a.pubkey)}
</div>
</div>
<AsyncButton
className="bg-neutral-700 hover:bg-neutral-600"
onClick={async () => {
if (
login?.api &&
confirm("Are you sure you want to delete this handle?")
) {
await login.api.deleteDomainHandle(a.domain_id, a.id);
const handles = await login.api.listDomainHandles(domain.id);
setHandles(handles);
}
}}
>
<Icon name="delete" size={30} />
</AsyncButton>
</div>
))}
</div>
<AsyncButton onClick={() => setAddHandle(true)}>Add Handle</AsyncButton>
{addHandle && (
<Modal id="add-handle" onClose={() => setAddHandle(false)}>
<div className="flex flex-col gap-4">
<div className="text-xl">Add Handle for {domain.name}</div>
<input
type="text"
placeholder="name"
value={newHandle}
onChange={(e) => setNewHandle(e.target.value)}
/>
<input
type="text"
placeholder="npub/nprofile/hex"
value={newHandlePubkey}
onChange={(e) => setNewHandlePubkey(e.target.value)}
/>
{newHandleError && (
<div className="text-red-500">{newHandleError}</div>
)}
<AsyncButton
onClick={async () => {
if (
login?.api &&
newHandle &&
newHandle.length > 0 &&
newHandlePubkey &&
newHandlePubkey.length > 0
) {
setNewHandleError(undefined);
try {
const pubkeyHex =
tryParseNostrLink(newHandlePubkey)?.id ?? newHandlePubkey;
await login.api.addDomainHandle(
domain.id,
newHandle,
pubkeyHex,
);
const handles = await login.api.listDomainHandles(
domain.id,
);
setHandles(handles);
setNewHandle(undefined);
setNewHandlePubkey(undefined);
setAddHandle(false);
} catch (e) {
if (e instanceof Error) {
setNewHandleError(e.message);
}
}
}
}}
>
Add
</AsyncButton>
</div>
</Modal>
)}
</div>
);
}

View File

@ -0,0 +1,153 @@
import useLogin from "../hooks/login";
import { useEffect, useState } from "react";
import { AccountDetail } from "../api";
import { AsyncButton } from "../components/button";
import { Icon } from "../components/icon";
import { default as iso } from "iso-3166-1";
export function AccountSettings() {
const login = useLogin();
const [acc, setAcc] = useState<AccountDetail>();
const [editEmail, setEditEmail] = useState(false);
useEffect(() => {
login?.api.getAccount().then(setAcc);
}, [login]);
if (!acc) return;
return (
<div className="flex flex-col gap-4">
<div className="text-xl">Account Settings</div>
<p className="text-neutral-400 text-sm">
Update your billing information to appear on generated invoices
(optional).
</p>
<div className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 items-center">
<div>Name</div>
<input
type="text"
value={acc.name}
onChange={(e) =>
setAcc((s) => (s ? { ...s, name: e.target.value } : undefined))
}
/>
<div>Address Line 1</div>
<input
type="text"
value={acc.address_1}
onChange={(e) =>
setAcc((s) => (s ? { ...s, address_1: e.target.value } : undefined))
}
/>
<div>Address Line 2</div>
<input
type="text"
value={acc.address_2}
onChange={(e) =>
setAcc((s) => (s ? { ...s, address_2: e.target.value } : undefined))
}
/>
<div>City</div>
<input
type="text"
value={acc.city}
onChange={(e) =>
setAcc((s) => (s ? { ...s, city: e.target.value } : undefined))
}
/>
<div>State</div>
<input
type="text"
value={acc.state}
onChange={(e) =>
setAcc((s) => (s ? { ...s, state: e.target.value } : undefined))
}
/>
<div>Postcode</div>
<input
type="text"
value={acc.postcode}
onChange={(e) =>
setAcc((s) => (s ? { ...s, postcode: e.target.value } : undefined))
}
/>
<div>Country</div>
<select
value={acc?.country_code}
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>Tax ID</div>
<input
type="text"
value={acc.tax_id}
onChange={(e) =>
setAcc((s) => (s ? { ...s, tax_id: e.target.value } : undefined))
}
/>
</div>
<div className="text-xl">Notification Settings</div>
<p className="text-neutral-400 text-sm">
This is only for account notifications such as VM expiration
notifications, we do not send marketing or promotional messages.
</p>
<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>
);
}

View File

@ -1,31 +1,77 @@
import { useEffect, useState } from "react";
import { LNVpsApi, VmInstance } from "../api";
import useLogin from "../hooks/login";
import { EventPublisher } from "@snort/system";
import { ApiUrl } from "../const";
import VpsInstanceRow from "../components/vps-instance";
import { hexToBech32 } from "@snort/shared";
import { AsyncButton } from "../components/button";
import { useNavigate } from "react-router-dom";
import { AccountNostrDomains } from "../components/account-domains";
export default function AccountPage() {
const login = useLogin();
const navigate = useNavigate();
const [vms, setVms] = useState<Array<VmInstance>>([]);
async function loadVms(api: LNVpsApi) {
const vms = await api.listVms();
setVms(vms);
}
useEffect(() => {
if (!login?.signer) return;
const api = new LNVpsApi(
ApiUrl,
new EventPublisher(login.signer, login.pubkey),
);
api.listVms().then(setVms);
if (login?.api) {
loadVms(login.api);
const t = setInterval(() => {
loadVms(login.api);
}, 5_000);
return () => clearInterval(t);
}
}, [login]);
const npub = hexToBech32("npub", login?.publicKey);
const subjectLine = `[${npub}] Account Query`;
return (
<>
<h3>My Resources</h3>
<div className="flex flex-col gap-2">
{vms.map((a) => (
<VpsInstanceRow key={a.id} vm={a} actions={false} />
))}
<div className="flex flex-col gap-2">
Your Public Key:
<pre className="bg-neutral-900 rounded-md px-3 py-2 select-all text-sm">
{npub}
</pre>
<div className="flex justify-between">
<AsyncButton onClick={() => navigate("settings")}>Settings</AsyncButton>
<AsyncButton
onClick={() => {
login?.logout();
navigate("/");
}}
>
Logout
</AsyncButton>
</div>
</>
<h3>My Resources</h3>
<div className="rounded-xl bg-red-400 text-black p-3">
Something doesnt look right? <br />
Please contact support on:{" "}
<a
href={`mailto:sales@lnvps.net?subject=${encodeURIComponent(subjectLine)}`}
className="underline"
>
sales@lnvps.net
</a>
<br />
<b>Please include your public key in all communications.</b>
</div>
{vms.length > 0 && <h3>VPS</h3>}
{vms.map((a) => (
<VpsInstanceRow
key={a.id}
vm={a}
onReload={() => {
if (login?.api) {
loadVms(login.api);
}
}}
/>
))}
<AccountNostrDomains />
</div>
);
}

View File

@ -1,43 +1,181 @@
import { useState, useEffect } from "react";
import { VmTemplate, LNVpsApi } from "../api";
import Profile from "../components/profile";
import { useState, useEffect, ReactNode } from "react";
import { DiskType, LNVpsApi, VmHostRegion, VmTemplateResponse } from "../api";
import VpsCard from "../components/vps-card";
import { ApiUrl, NostrProfile } from "../const";
import { Link } from "react-router-dom";
import { VpsCustomOrder } from "../components/vps-custom";
import { LatestNews } from "../components/latest-news";
import { FilterButton } from "../components/button-filter";
import { appendDedupe, dedupe } from "@snort/shared";
import useLogin from "../hooks/login";
export default function HomePage() {
const [offers, setOffers] = useState<Array<VmTemplate>>([]);
const login = useLogin();
const [offers, setOffers] = useState<VmTemplateResponse>();
const [region, setRegion] = useState<Array<number>>([]);
const [diskType, setDiskType] = useState<Array<DiskType>>([]);
const regions = (offers?.templates.map((t) => t.region) ?? []).reduce(
(acc, v) => {
if (acc[v.id] === undefined) {
acc[v.id] = v;
}
return acc;
},
{} as Record<number, VmHostRegion>,
);
const diskTypes = dedupe(offers?.templates.map((t) => t.disk_type) ?? []);
useEffect(() => {
const api = new LNVpsApi(ApiUrl, undefined);
api.listOffers().then((o) => setOffers(o));
api.listOffers().then((o) => {
setOffers(o);
setRegion(dedupe(o.templates.map((z) => z.region.id)));
setDiskType(dedupe(o.templates.map((z) => z.disk_type)));
});
}, []);
return (
<>
<h1>VPS Offers</h1>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-3 gap-2">
{offers.map((a) => (
<VpsCard spec={a} key={a.id} />
))}
<div className="flex flex-col gap-4">
<LatestNews />
<div className="text-2xl">VPS Offers</div>
<div>
Virtual Private Server hosting with flexible plans, high uptime, and
dedicated support, tailored to your needs.
</div>
<small>
All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic
<div className="flex gap-4 items-center">
{Object.keys(regions).length > 1 && (
<FilterSection header={"Region"}>
{Object.values(regions).map((r) => {
return (
<FilterButton
active={region.includes(r.id)}
onClick={() =>
setRegion((x) => {
if (x.includes(r.id)) {
return x.filter((y) => y != r.id);
} else {
return appendDedupe(x, [r.id]);
}
})
}
>
{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">
{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 && (
<div className="text-red-500 bold text-xl uppercase">
No offers available
</div>
)}
</div>
{offers?.custom_template && (
<VpsCustomOrder templates={offers.custom_template} />
)}
<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.
</small>
<div className="flex flex-col gap-4">
<b>You can also find us on nostr: </b>
<a target="_blank" href={`nostr:${NostrProfile.encode()}`}>
<Profile link={NostrProfile} />
</a>
<div>
<a target="_blank" href="http://speedtest.v0l.io">
<div className="text-center">
<a href="/lnvps.asc">PGP</a>
{" | "}
<Link to="/status">Status</Link>
{" | "}
<Link to="/tos">Terms</Link>
{" | "}
<Link to="/news">News</Link>
{" | "}
<a
href={`https://snort.social/${NostrProfile.encode()}`}
target="_blank"
>
Nostr
</a>
{" | "}
<a href="https://github.com/LNVPS" target="_blank">
Git
</a>
{" | "}
<a href="http://speedtest.v0l.io" target="_blank">
Speedtest
</a>{" "}
| <a href="/public/lnvps.asc">PGP</a>
</a>
</div>
{import.meta.env.VITE_FOOTER_NOTE_1 && (
<div className="text-xs text-center text-neutral-400">
{import.meta.env.VITE_FOOTER_NOTE_1}
</div>
)}
{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">
Currency:{" "}
<select
value={login?.currency ?? "EUR"}
onChange={(e) =>
login?.update((s) => (s.currency = e.target.value))
}
>
{["BTC", "EUR", "USD", "GBP", "AUD", "CAD", "CHF", "JPY"].map(
(a) => (
<option>{a}</option>
),
)}
</select>
</div>
</div>
</div>
</>
);
}
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

@ -1,11 +1,15 @@
import { Link, Outlet } from "react-router-dom";
import LoginButton from "../components/login-button";
import { saveRefCode } from "../ref";
export default function Layout() {
saveRefCode();
return (
<div className="w-[700px] mx-auto m-2 p-2">
<div className="flex items-center justify-between mb-4">
<Link to="/">LNVPS</Link>
<Link to="/" className="text-2xl">
LNVPS
</Link>
<LoginButton />
</div>

25
src/pages/news-post.tsx Normal file
View File

@ -0,0 +1,25 @@
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useLocation } from "react-router-dom";
import Markdown from "../components/markdown";
import Profile from "../components/profile";
export function NewsPost() {
const { state } = useLocation() as { state?: TaggedNostrEvent };
if (!state) return;
const title = state.tags.find((a) => a[0] == "title")?.[1];
const posted = Number(
state.tags.find((a) => a[0] == "published_at")?.[1] ?? state.created_at,
);
return (
<div>
<div className="text-2xl">{title}</div>
<div className="flex items-center justify-between py-8">
<Profile link={NostrLink.profile(state.pubkey, state.relays)} />
<div>{new Date(posted * 1000).toLocaleString()}</div>
</div>
<Markdown content={state.content} />
</div>
);
}

35
src/pages/news.tsx Normal file
View File

@ -0,0 +1,35 @@
import { EventKind, RequestBuilder } from "@snort/system";
import { NostrProfile } from "../const";
import { useRequestBuilder } from "@snort/system-react";
import { NewLink } from "../components/news-link";
export function NewsPage() {
const req = new RequestBuilder("news");
req
.withFilter()
.kinds([EventKind.LongFormTextNote])
.authors([NostrProfile.id])
.limit(10);
const posts = useRequestBuilder(req);
return (
<div className="flex flex-col gap-4">
<div className="text-2xl">News</div>
{posts
.sort((a, b) => {
const a_posted = Number(
a.tags.find((a) => a[0] == "published_at")?.[1] ?? a.created_at,
);
const b_posted = Number(
b.tags.find((z) => z[0] == "published_at")?.[1] ?? b.created_at,
);
return b_posted - a_posted;
})
.map((a) => (
<NewLink ev={a} />
))}
{posts.length === 0 && <div>No posts yet..</div>}
</div>
);
}

View File

@ -1,80 +1,53 @@
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 CostLabel from "../components/cost";
import { ApiUrl } from "../const";
import { EventPublisher } from "@snort/system";
import useLogin from "../hooks/login";
import { AsyncButton } from "../components/button";
import classNames from "classnames";
import VpsResources from "../components/vps-resources";
import OsImageName from "../components/os-image-name";
import SSHKeySelector from "../components/ssh-keys";
import { clearRefCode, getRefCode } from "../ref";
export default function OrderPage() {
const { state } = useLocation();
const login = useLogin();
const navigate = useNavigate();
const template = state as VmTemplate | undefined;
const [newKey, setNewKey] = useState("");
const [newKeyError, setNewKeyError] = useState("");
const [newKeyName, setNewKeyName] = useState("");
const [useImage, setUseImage] = useState(-1);
const [useSshKey, setUseSshKey] = useState(-1);
const [showAddKey, setShowAddKey] = useState(false);
const [images, setImages] = useState<Array<VmOsImage>>([]);
const [sshKeys, setSshKeys] = useState<Array<UserSshKey>>([]);
const [orderError, setOrderError] = useState("");
useEffect(() => {
if (!login?.signer) return;
const api = new LNVpsApi(
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);
}
});
if (!login?.api) return;
login.api.listOsImages().then((a) => setImages(a));
}, [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() {
if (!login?.signer || !template) return;
const api = new LNVpsApi(
ApiUrl,
new EventPublisher(login.signer, login.pubkey),
);
if (!login?.api || !template) return;
setOrderError("");
try {
const newVm = await api.orderVm(template.id, useImage, useSshKey);
navigate("/vm/renew", {
const ref = getRefCode();
const newVm = template.pricing_id
? await login.api.orderCustom(
{
cpu: template.cpu,
memory: template.memory,
disk: template.disk_size,
disk_type: template.disk_type,
disk_interface: template.disk_interface,
pricing_id: template.pricing_id!,
},
useImage,
useSshKey,
ref?.code,
)
: await login.api.orderVm(template.id, useImage, useSshKey, ref?.code);
clearRefCode();
navigate("/vm/billing/renew", {
state: newVm,
});
} catch (e) {
@ -122,53 +95,7 @@ export default function OrderPage() {
))}
</div>
<hr />
<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={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>
<SSHKeySelector selectedKey={useSshKey} setSelectedKey={setUseSshKey} />
<AsyncButton
disabled={useSshKey === -1 || useImage === -1}
onClick={createOrder}

174
src/pages/sign-up.tsx Normal file
View 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>
);
}

56
src/pages/status.tsx Normal file
View File

@ -0,0 +1,56 @@
import Markdown from "../components/markdown";
import Status from "../status.json";
export function StatusPage() {
const totalDowntime = Status.events.reduce((acc, v) => {
if (v.end_time) {
const end = new Date(v.end_time);
const start = new Date(v.start_time);
const duration = end.getTime() - start.getTime();
acc += duration;
}
return acc;
}, 0);
const birth = new Date(Status.birth);
const now = new Date();
const age = now.getTime() - birth.getTime();
const uptime = 1 - totalDowntime / age;
function formatDuration(n: number) {
if (n > 3600) {
return `${(n / 3600).toFixed(0)}h ${((n % 3600) / 60).toFixed(0)}m`;
} else if (n > 60) {
return `${(n % 60).toFixed(0)}m`;
} else {
return `${n.toFixed(0)}s`;
}
}
return (
<div className="flex flex-col gap-4">
<div className="text-2xl">Uptime: {(100 * uptime).toFixed(5)}%</div>
<div className="text-xl">Incidents:</div>
{Status.events.map((e) => {
const end = e.end_time ? new Date(e.end_time) : undefined;
const start = new Date(e.start_time);
const duration = end ? end.getTime() - start.getTime() : undefined;
return (
<div className="rounded-xl bg-neutral-900 px-3 py-4 flex flex-col gap-2">
<div className="text-xl flex justify-between">
<div>{e.title}</div>
<div>{new Date(e.start_time).toLocaleString()}</div>
</div>
{duration && (
<div className="text-sm text-neutral-400">
Duration: {formatDuration(duration / 1000)}
</div>
)}
<Markdown content={e.description} />
</div>
);
})}
</div>
);
}

6
src/pages/terms.tsx Normal file
View File

@ -0,0 +1,6 @@
import Markdown from "../components/markdown";
import TOS from "../tos.md?raw";
export function TosPage() {
return <Markdown content={TOS} />;
}

287
src/pages/vm-billing.tsx Normal file
View File

@ -0,0 +1,287 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { PaymentMethod, VmInstance, VmPayment } from "../api";
import VpsPayment from "../components/vps-payment";
import useLogin from "../hooks/login";
import { AsyncButton } from "../components/button";
import CostLabel, { CostAmount } from "../components/cost";
import { RevolutPayWidget } from "../components/revolut";
import { timeValue } from "../utils";
import { Icon } from "../components/icon";
import { ApiUrl } from "../const";
import QrCode from "../components/qr";
import { LNURL } from "@snort/shared";
export function VmBillingPage() {
const location = useLocation() as { state?: VmInstance };
const params = useParams();
const login = useLogin();
const navigate = useNavigate();
const [methods, setMethods] = useState<Array<PaymentMethod>>();
const [method, setMethod] = useState<PaymentMethod>();
const [payment, setPayment] = useState<VmPayment>();
const [payments, setPayments] = useState<Array<VmPayment>>([]);
const [state, setState] = useState<VmInstance | undefined>(location?.state);
async function listPayments() {
if (!state) return;
const history = await login?.api.listPayments(state.id);
setPayments(history ?? []);
}
async function reloadVmState() {
if (!state) return;
const newState = await login?.api.getVm(state.id);
setState(newState);
setMethod(undefined);
setMethods(undefined);
return newState;
}
async function onPaid() {
setMethod(undefined);
setMethods(undefined);
const s = reloadVmState();
if (params["action"] === "renew") {
navigate("/vm", { state: s });
}
}
function paymentMethod(v: PaymentMethod) {
const className =
"flex items-center justify-between px-3 py-2 bg-neutral-900 rounded-xl cursor-pointer";
const nameRow = (v: PaymentMethod) => {
return (
<div>
{v.name.toUpperCase()} ({v.currencies.join(",")})
</div>
);
};
switch (v.name) {
case "lnurl": {
const addr = v.metadata?.["address"];
return (
<div
key={v.name}
className={className}
onClick={() => {
setMethod(v);
}}
>
{nameRow(v)}
<div>{addr}</div>
</div>
);
}
case "lightning": {
return (
<div
key={v.name}
className={className}
onClick={() => {
setMethod(v);
renew(v.name);
}}
>
{nameRow(v)}
<div className="rounded-lg p-2 bg-green-800">Pay Now</div>
</div>
);
}
case "revolut": {
const pkey = v.metadata?.["pubkey"];
if (!pkey) return <b>Missing Revolut pubkey</b>;
return (
<div key={v.name} className={className}>
{nameRow(v)}
{state && (
<RevolutPayWidget
mode={import.meta.env.VITE_REVOLUT_MODE}
pubkey={pkey}
amount={state.template.cost_plan}
onPaid={() => {
onPaid();
}}
loadOrder={async () => {
if (!login?.api || !state) {
throw new Error("Not logged in");
}
const p = await login.api.renewVm(state.id, v.name);
return p.data.revolut!.token;
}}
/>
)}
</div>
);
}
}
}
const loadPaymentMethods = useCallback(
async function () {
if (!login?.api || !state) return;
const p = await login?.api.getPaymentMethods();
setMethods(p);
},
[login?.api, state],
);
const renew = useCallback(
async function (m: string) {
if (!login?.api || !state) return;
const p = await login?.api.renewVm(state.id, m);
setPayment(p);
},
[login?.api, state],
);
useEffect(() => {
if (params["action"] === "renew" && login && state) {
loadPaymentMethods();
}
if (login && state) {
listPayments();
}
}, [login, state, params, renew]);
if (!state) return;
const expireDate = new Date(state.expires);
const days =
(expireDate.getTime() - new Date().getTime()) / 1000 / 24 / 60 / 60;
const lud16 = `${state.id}@${new URL(ApiUrl).host}`;
// Static LNURL payment method
const lnurl = {
name: "lnurl",
currencies: ["BTC"],
metadata: {
address: lud16,
},
} as PaymentMethod;
return (
<div className="flex flex-col gap-4">
<Link to={"/vm"} state={state}>
&lt; Back
</Link>
<div className="text-xl bg-neutral-900 rounded-xl px-3 py-4 flex justify-between items-center">
<div>Renewal for #{state.id}</div>
<div>
<CostLabel cost={state.template.cost_plan} />
<span className="text-sm">ex. tax</span>
</div>
</div>
{days > 0 && (
<div>
Expires: {expireDate.toDateString()} ({Math.floor(days)} days)
</div>
)}
{days < 0 && !methods && (
<div className="text-red-500 text-xl">Expired</div>
)}
{!methods && (
<div>
<AsyncButton onClick={loadPaymentMethods}>Extend Now</AsyncButton>
</div>
)}
{methods && !method && (
<>
<div className="text-xl">Payment Method:</div>
{[lnurl, ...methods].map((v) => paymentMethod(v))}
</>
)}
{method?.name === "lnurl" && (
<>
<div className="flex flex-col gap-4 rounded-xl p-3 bg-neutral-900 items-center">
<QrCode
data={`lightning:${new LNURL(lud16).lnurl}`}
width={512}
height={512}
avatar="/logo.jpg"
className="cursor-pointer rounded-xl overflow-hidden"
/>
<div className="monospace select-all break-all text-center text-sm">
{lud16}
</div>
</div>
</>
)}
{payment && (
<>
<h3>Renew VPS</h3>
<VpsPayment
payment={payment}
onPaid={async () => {
setPayment(undefined);
onPaid();
}}
/>
</>
)}
{!methods && (
<>
<div className="text-xl">Payment History</div>
<table className="table bg-neutral-900 rounded-xl text-center">
<thead>
<tr>
<th>Date</th>
<th>Amount</th>
<th>Time</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{payments
.sort(
(a, b) =>
new Date(b.created).getTime() -
new Date(a.created).getTime(),
)
.map((a) => (
<tr key={a.id}>
<td className="pl-4">
{new Date(a.created).toLocaleString()}
</td>
<td>
<CostAmount
cost={{
amount:
(a.amount + a.tax) /
(a.currency === "BTC" ? 1e11 : 100),
currency: a.currency,
}}
converted={false}
/>
</td>
<td>{timeValue(a.time)}</td>
<td>
{a.is_paid
? "Paid"
: new Date(a.expires) <= new Date()
? "Expired"
: "Unpaid"}
</td>
<td>
{a.is_paid && (
<div
title="Generate Invoice"
onClick={async () => {
const l = await login?.api.invoiceLink(a.id);
window.open(l, "_blank");
}}
>
<Icon name="printer" />
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
);
}

57
src/pages/vm-console.tsx Normal file
View File

@ -0,0 +1,57 @@
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import useLogin from "../hooks/login";
import { VmInstance } from "../api";
import { WebglAddon } from "@xterm/addon-webgl";
import { AttachAddon } from "@xterm/addon-attach";
const fit = new FitAddon();
export function VmConsolePage() {
const { state } = useLocation() as { state?: VmInstance };
const login = useLogin();
const [term, setTerm] = useState<Terminal>();
const termRef = useRef<HTMLDivElement | null>(null);
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);
const attach = new AttachAddon(ws);
attach.activate(te);
setTerm((t) => {
if (t) {
t.dispose();
}
return te;
});
}
useEffect(() => {
if (term && termRef.current) {
termRef.current.innerHTML = "";
term.open(termRef.current);
term.focus();
fit.fit();
}
}, [termRef, term]);
useEffect(() => {
openTerminal();
}, []);
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>
);
}

185
src/pages/vm-graphs.tsx Normal file
View File

@ -0,0 +1,185 @@
import { Link, useLocation } from "react-router-dom";
import { TimeSeriesData, VmInstance } from "../api";
import useLogin from "../hooks/login";
import { useEffect, useState } from "react";
import {
ResponsiveContainer,
XAxis,
YAxis,
Tooltip,
LineChart,
Line,
Legend,
} from "recharts";
export function VmGraphsPage() {
const { state } = useLocation() as { state?: VmInstance };
const login = useLogin();
const [data, setData] = useState<Array<TimeSeriesData>>();
useEffect(() => {
if (!state) return;
login?.api.getVmTimeSeries(state.id).then(setData);
}, [login]);
const maxRam =
data?.reduce((acc, v) => {
const mb = v.memory_size / 1024 / 1024;
return acc < mb ? mb : acc;
}, 0) ?? 0;
const KB = 1024;
const MB = 1024 * 1024;
function scaleLabel(v: number) {
switch (v) {
case MB:
return "MiB";
case KB:
return "KiB";
}
return "B";
}
const net_scale =
data?.reduce((acc, v) => {
const b = Math.max(v.net_in, v.net_out);
if (b > MB && b > acc) {
return MB;
} else if (b > KB && b > acc) {
return KB;
} else {
return acc;
}
}, 0) ?? 0;
const net_scale_label = scaleLabel(net_scale);
const disk_scale =
data?.reduce((acc, v) => {
const b = Math.max(v.disk_read, v.disk_write);
if (b > MB && b > acc) {
return MB;
} else if (b > KB && b > acc) {
return KB;
} else {
return acc;
}
}, 0) ?? 0;
const disk_scale_label = scaleLabel(disk_scale);
const sortedData = (data ?? [])
.sort((a, b) => a.timestamp - b.timestamp)
.map((v) => ({
timestamp: new Date(v.timestamp * 1000).toLocaleTimeString(),
CPU: 100 * v.cpu,
RAM: v.memory / 1024 / 1024,
NET_IN: v.net_in / net_scale,
NET_OUT: v.net_out / net_scale,
DISK_READ: v.disk_read / disk_scale,
DISK_WRITE: v.disk_write / disk_scale,
}));
const toolTip = (
<Tooltip
cursor={{ fill: "rgba(200,200,200,0.5)" }}
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload as TimeSeriesData;
return (
<div className="flex flex-col gap-2 bg-neutral-700 rounded-xl px-2 py-3">
<div>{data.timestamp}</div>
{payload.map((p) => (
<div>
{p.name}: {Number(p.value).toFixed(2)}
{p.unit}
</div>
))}
</div>
);
}
}}
/>
);
return (
<div className="flex flex-col gap-4">
<Link to={"/vm"} state={state}>
&lt; Back
</Link>
<h2>CPU</h2>
<ResponsiveContainer height={200}>
<LineChart
data={sortedData}
margin={{ left: 0, right: 0 }}
style={{ userSelect: "none" }}
>
<XAxis dataKey="timestamp" />
<YAxis unit="%" domain={[0, 100]} />
<Line type="monotone" dataKey="CPU" unit="%" dot={false} />
{toolTip}
</LineChart>
</ResponsiveContainer>
<h2>Memory</h2>
<ResponsiveContainer height={200}>
<LineChart
data={sortedData}
margin={{ left: 0, right: 0 }}
style={{ userSelect: "none" }}
>
<XAxis dataKey="timestamp" />
<YAxis unit="MB" domain={[0, maxRam]} />
<Line type="monotone" dataKey="RAM" unit="MB" dot={false} />
{toolTip}
</LineChart>
</ResponsiveContainer>
<h2>Network</h2>
<ResponsiveContainer height={200}>
<LineChart
data={sortedData}
margin={{ left: 20, right: 0 }}
style={{ userSelect: "none" }}
>
<XAxis dataKey="timestamp" />
<YAxis unit={`${net_scale_label}/s`} domain={[0, "auto"]} />
<Line
type="monotone"
dataKey="NET_IN"
unit={`${net_scale_label}/s`}
stroke="red"
dot={false}
/>
<Line
type="monotone"
dataKey="NET_OUT"
unit={`${net_scale_label}/s`}
stroke="green"
dot={false}
/>
{toolTip}
<Legend />
</LineChart>
</ResponsiveContainer>
<h2>Disk</h2>
<ResponsiveContainer height={200}>
<LineChart
data={sortedData}
margin={{ left: 20, right: 0 }}
style={{ userSelect: "none" }}
>
<XAxis dataKey="timestamp" />
<YAxis unit={`${disk_scale_label}/s`} domain={[0, "auto"]} />
<Line
type="monotone"
dataKey="DISK_READ"
unit={`${disk_scale_label}/s`}
stroke="red"
dot={false}
/>
<Line
type="monotone"
dataKey="DISK_WRITE"
unit={`${disk_scale_label}/s`}
stroke="green"
dot={false}
/>
{toolTip}
<Legend />
</LineChart>
</ResponsiveContainer>
</div>
);
}

View File

@ -1,85 +1,195 @@
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { LNVpsApi, VmInstance, VmPayment } from "../api";
import "@xterm/xterm/css/xterm.css";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { VmInstance, VmIpAssignment } from "../api";
import VpsInstanceRow from "../components/vps-instance";
import useLogin from "../hooks/login";
import { ApiUrl } from "../const";
import { EventPublisher } from "@snort/system";
import { useCallback, useEffect, useState } from "react";
import VpsPayment from "../components/vps-payment";
import CostLabel from "../components/cost";
import { useEffect, useState } from "react";
import { AsyncButton } from "../components/button";
import { Icon } from "../components/icon";
import Modal from "../components/modal";
import SSHKeySelector from "../components/ssh-keys";
export default function VmPage() {
const { state } = useLocation() as { state?: VmInstance };
const { action } = useParams();
const location = useLocation() as { state?: VmInstance };
const login = useLogin();
const navigate = useNavigate();
const [payment, setPayment] = useState<VmPayment>();
const [state, setState] = useState<VmInstance | undefined>(location?.state);
const renew = useCallback(
async function () {
if (!login?.signer || !state) return;
const api = new LNVpsApi(
ApiUrl,
new EventPublisher(login.signer, login.pubkey),
);
const p = await api.renewVm(state.id);
setPayment(p);
},
[login, state],
);
const [editKey, setEditKey] = useState(false);
const [editReverse, setEditReverse] = useState<VmIpAssignment>();
const [error, setError] = useState<string>();
const [key, setKey] = useState(state?.ssh_key?.id ?? -1);
async function reloadVmState() {
if (!state) return;
const newState = await login?.api.getVm(state.id);
setState(newState);
}
function ipRow(a: VmIpAssignment, reverse: boolean) {
return (
<div
key={a.id}
className="bg-neutral-900 px-2 py-3 rounded-lg flex gap-2 flex-col justify-center"
>
<div>
<span className="select-none">IP: </span>
<span className="select-all">{a.ip.split("/")[0]}</span>
</div>
{a.forward_dns && (
<div className="text-sm select-none">
DNS: <span className="select-all">{a.forward_dns}</span>
</div>
)}
{reverse && (
<div className="text-sm select-none flex items-center gap-2">
<div>
PTR: <span className="select-all">{a.reverse_dns}</span>
</div>
<Icon
name="pencil"
className="inline"
size={15}
onClick={() => setEditReverse(a)}
/>
</div>
)}
</div>
);
}
const hasNoIps = (state?.ip_assignments?.length ?? 0) === 0;
function networkInfo() {
if (!state) return;
if (hasNoIps) {
return <div className="text-sm text-red-500">No IP's assigned</div>;
}
return <>{state.ip_assignments?.map((i) => ipRow(i, true))}</>;
}
useEffect(() => {
switch (action) {
case "renew":
renew();
const t = setInterval(() => reloadVmState(), 5000);
return () => clearInterval(t);
}, []);
function bestHost() {
if (!state) return;
if (state.ip_assignments.length > 0) {
const ip = state.ip_assignments.at(0)!;
return ip.forward_dns ? ip.forward_dns : ip.ip.split("/")[0];
}
}, [renew, action]);
}
if (!state) {
return <h2>No VM selected</h2>;
}
return (
<div className="flex flex-col gap-4">
<VpsInstanceRow vm={state} actions={false} />
{action === undefined && <>
<div className="text-xl">Renewal</div>
<div className="flex justify-between items-center">
<div>{new Date(state.expires).toDateString()}</div>
{state.template?.cost_plan && <div><CostLabel cost={state.template?.cost_plan} /></div>}
<Link to={"/account"}>&lt; Back</Link>
<VpsInstanceRow vm={state} actions={true} />
<div className="text-xl">Network:</div>
<div className="grid grid-cols-2 gap-4">{networkInfo()}</div>
<div className="text-xl">SSH:</div>
<div className="grid grid-cols-2 gap-4">
<div className="bg-neutral-900 px-2 py-3 rounded-lg flex gap-2 items-center">
<div>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>
<AsyncButton onClick={() => navigate("/vm/renew", { state })}>
Extend Now
{!hasNoIps && (
<div className="bg-neutral-900 px-2 py-3 rounded-lg flex gap-2 items-center">
<div>Login:</div>
<pre className="select-all bg-neutral-800 px-3 py-1 rounded-full">
ssh {state.image.default_username}@{bestHost()}
</pre>
</div>
)}
</div>
<hr />
<div className="flex gap-4">
{/*<AsyncButton onClick={() => navigate("/vm/console", { state })}>
Console
</AsyncButton>*/}
<AsyncButton onClick={() => navigate("/vm/billing", { state })}>
Billing
</AsyncButton>
<div className="text-xl">Network</div>
<div className="flex gap-4">
{(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>
</>}
{action === "renew" && (
<>
<h3>Renew VPS</h3>
{payment && (
<VpsPayment
payment={payment}
onPaid={async () => {
if (!login?.signer || !state) return;
const api = new LNVpsApi(
ApiUrl,
new EventPublisher(login.signer, login.pubkey),
);
const newState = await api.getVm(state.id);
navigate("/vm", {
state: newState,
});
setPayment(undefined);
<AsyncButton onClick={() => navigate("/vm/graphs", { state })}>
Graphs
</AsyncButton>
</div>
{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>
{error && <b className="text-red-500">{error}</b>}
<AsyncButton
onClick={async () => {
setError(undefined);
if (!login?.api) return;
try {
await login.api.patchVm(state.id, {
ssh_key_id: key,
});
await reloadVmState();
setEditKey(false);
} catch (e) {
if (e instanceof Error) {
setError(e.message);
}
}
}}
>
Save
</AsyncButton>
</div>
</Modal>
)}
{editReverse && (
<Modal id="edit-reverse" onClose={() => setEditReverse(undefined)}>
<div className="flex flex-col gap-4">
<div className="text-lg">Reverse DNS:</div>
<input
type="text"
placeholder="my-domain.com"
value={editReverse.reverse_dns}
onChange={(e) =>
setEditReverse({
...editReverse,
reverse_dns: e.target.value,
})
}
/>
)}
</>
<small>DNS updates can take up to 48hrs to propagate.</small>
{error && <b className="text-red-500">{error}</b>}
<AsyncButton
onClick={async () => {
setError(undefined);
if (!login?.api) return;
try {
await login.api.patchVm(state.id, {
reverse_dns: editReverse.reverse_dns,
});
await reloadVmState();
setEditReverse(undefined);
} catch (e) {
if (e instanceof Error) {
setError(e.message);
}
}
}}
>
Save
</AsyncButton>
</div>
</Modal>
)}
</div>
);

37
src/ref.ts Normal file
View File

@ -0,0 +1,37 @@
export interface RefCode {
code: string;
saved: number;
}
export function saveRefCode() {
const search = new URLSearchParams(window.location.search);
const code = search.get("ref");
if (code) {
// save or overwrite new code from landing
window.localStorage.setItem(
"ref",
JSON.stringify({
code,
saved: Math.floor(new Date().getTime() / 1000),
}),
);
window.location.search = "";
}
}
export function getRefCode() {
const ref = window.localStorage.getItem("ref");
if (ref) {
const refObj = JSON.parse(ref) as RefCode;
const now = Math.floor(new Date().getTime() / 1000);
// treat code as stale if > 7days old
if (Math.abs(refObj.saved - now) > 604800) {
window.localStorage.removeItem("ref");
}
return refObj;
}
}
export function clearRefCode() {
window.localStorage.removeItem("ref");
}

11
src/status.json Normal file
View File

@ -0,0 +1,11 @@
{
"birth": "2024-06-05T00:00:00Z",
"events": [
{
"start_time": "2025-02-10T05:00:00Z",
"end_time": "2025-02-10T10:08:00Z",
"title": "VPS outage",
"description": "Primary disk full, causing system to halt"
}
]
}

126
src/tos.md Normal file
View File

@ -0,0 +1,126 @@
# Terms of Service
**LNVPS**
_Last Updated: February 26, 2025_
Welcome to LNVPS, a trading name of Apex Strata Ltd, a company registered in Ireland. These Terms of Service ("Terms") govern your use of our Virtual Private Server (VPS) hosting services, website, and related offerings (collectively, the "Services"). By accessing or using our Services, you agree to be bound by these Terms. If you do not agree, please do not use our Services.
---
## 1. Company Information
LNVPS is a trading name of Apex Strata Ltd, a company registered in Ireland.
- **Company Registration Number**: 702423
- **Registered Office**: Suite 10628, 26/27 Upper Pembroke Street, Dublin 2, D02 X361, Ireland
- **Email**: sales@lnvps.net
---
## 2. Definitions
- **"You" or "Customer"**: The individual or entity subscribing to or using the Services.
- **"We", "Us", or "LNVPS"**: Apex Strata Ltd, operating as LNVPS.
- **"Services"**: VPS hosting, support, and any additional features provided by LNVPS.
---
## 3. Eligibility
You must be at least 18 years old and capable of entering into a legally binding agreement to use our Services. By signing up, you confirm that all information provided is accurate and that you are authorized to act on behalf of any entity you represent.
---
## 4. Services
LNVPS provides VPS hosting services, including server resources, bandwidth, and technical support, as outlined on our website ([lnvps.net](https://lnvps.net) or applicable domain). Specific features, pricing, and resource limits are detailed in your chosen service plan at the time of purchase.
- **Service Availability**: We strive for 99.9% uptime but do not guarantee uninterrupted service. Downtime may occur due to maintenance, upgrades, or unforeseen events.
- **Modifications**: We reserve the right to modify or discontinue any aspect of the Services with reasonable notice.
---
## 5. Account Responsibilities
- **Account Security**: You are responsible for maintaining the confidentiality of your account credentials and for all activities under your account.
- **Usage**: You agree to use the Services only for lawful purposes and in compliance with these Terms.
- **Notification**: You must notify us immediately of any unauthorized use of your account.
---
## 6. Acceptable Use Policy
You agree not to use the Services to:
- Host, store, or distribute illegal content, including but not limited to pirated software, child exploitation material, or content inciting violence or hate.
- Engage in spamming, phishing, or other abusive activities.
- Overload or disrupt our servers, networks, or other customers services (e.g., DDoS attacks).
- Violate intellectual property rights or privacy laws.
We reserve the right to suspend or terminate your Services without notice if we detect violations, subject to applicable law.
---
## 7. Payment and Billing
- **Fees**: You agree to pay the fees for your chosen plan as outlined at checkout. All prices are in Euro (€) and include VAT where applicable.
- **Billing Cycle**: Payments are due in advance (monthly, quarterly, or annually, depending on your plan).
- **Late Payment**: Overdue accounts may be suspended until payment is received.
- **Refunds**: Refunds are available within 7 days of initial purchase, provided no excessive usage has occurred, as determined by us.
---
## 8. Termination
- **By You**: You may terminate your account at any time via your control panel or by contacting us, subject to the billing cycle terms.
- **By Us**: We may suspend or terminate your Services for non-payment, violation of these Terms, or if required by law, with or without notice depending on the severity of the breach.
- **Effect of Termination**: Upon termination, your access to the Services ends, and we may delete your data after 7 days unless otherwise required by law.
---
## 9. Data and Privacy
- **Your Data**: You retain ownership of data uploaded to your VPS. We do not access it except as needed to provide the Services or comply with legal obligations.
- **Backups**: You are responsible for maintaining backups of your data unless a backup service is included in your plan.
- **GDPR Compliance**: We process personal data in accordance with our [Privacy Policy](#), which complies with the General Data Protection Regulation (GDPR).
---
## 10. Limitation of Liability
To the fullest extent permitted by Irish law:
- Our liability for any claim arising from the Services is limited to the amount you paid us in the previous 12 months.
- We are not liable for indirect, consequential, or incidental damages (e.g., loss of profits, data, or business opportunities).
- We are not responsible for issues beyond our reasonable control, such as force majeure events (e.g., natural disasters, cyber-attacks).
---
## 11. Intellectual Property
- **Our IP**: The LNVPS website, branding, and software remain our property or that of our licensors.
- **Your IP**: You grant us a limited license to use your content solely to provide the Services.
---
## 12. Governing Law and Dispute Resolution
- These Terms are governed by the laws of Ireland.
- Any disputes will be subject to the exclusive jurisdiction of the courts of Ireland, though you may have additional rights under EU consumer law if applicable.
---
## 13. Changes to Terms
We may update these Terms from time to time. We will notify you of significant changes via email or on our website. Continued use of the Services after changes constitutes acceptance of the updated Terms.
---
## 14. Contact Us
For questions or support:
- **Email**: sales@lnvps.net
- **Address**: Suite 10628, 26/27 Upper Pembroke Street, Dublin 2, D02 X361, Ireland
---

79
src/utils.ts Normal file
View File

@ -0,0 +1,79 @@
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();
}
export function timeValue(n: number): string {
if (!Number.isFinite(n) || n < 0) {
return "Invalid input";
}
if (n >= 86400) {
const days = Math.floor(n / 86400);
return days.toLocaleString() + " day" + (days !== 1 ? "s" : "");
}
if (n >= 3600) {
const hours = Math.floor(n / 3600);
const minutes = Math.floor((n % 3600) / 60);
return (
hours +
" hr" +
(hours !== 1 ? "s" : "") +
(minutes > 0 ? " " + minutes + " min" + (minutes !== 1 ? "s" : "") : "")
);
}
if (n >= 60) {
const minutes = Math.floor(n / 60);
return minutes + " min" + (minutes !== 1 ? "s" : "");
}
return n + " sec" + (n !== 1 ? "s" : "");
}

View File

@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,

View File

@ -4,4 +4,5 @@ import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
assetsInclude: ["**/*.md"],
});

927
yarn.lock

File diff suppressed because it is too large Load Diff