Compare commits
59 Commits
1f5506a920
...
main
Author | SHA1 | Date | |
---|---|---|---|
0f8ee33279
|
|||
f28c785cbd
|
|||
e4bee1a568
|
|||
51d7cea581
|
|||
9a04548627
|
|||
93de2704cc
|
|||
d2ee1acae7
|
|||
1834242198
|
|||
5176386849
|
|||
db66cd4dc3
|
|||
74acc4ee42
|
|||
9d70de9b8a
|
|||
c67dd4c793
|
|||
63c737b160
|
|||
a3836f445e
|
|||
0042a706bc
|
|||
3c3218044b
|
|||
c0b7836ce3
|
|||
1f38e22053 | |||
26d36adbeb
|
|||
c1312d97f1
|
|||
57cc619b8c
|
|||
7cba506d6b
|
|||
8e3e4c0364
|
|||
1aab7c9372
|
|||
0b93b0d4f9
|
|||
c6e4a9e3c9
|
|||
7ba2659fbf
|
|||
b52735a0a4
|
|||
7bdea28bc9
|
|||
d05c69af9c
|
|||
c5d45b0843
|
|||
072e791d2c
|
|||
669b852106
|
|||
cea6beee73
|
|||
5b3ff37ca0
|
|||
43886867e3
|
|||
88c8574966
|
|||
13353251ed
|
|||
7af41a1480
|
|||
912bb21022
|
|||
bfb2b072f9
|
|||
5af12b4f07
|
|||
fd1df3ce3d
|
|||
8758116520
|
|||
31f0a3c925
|
|||
5ecc59cda7
|
|||
247b4c61c8
|
|||
9c84a927d3
|
|||
9f364d2854
|
|||
e9d88279cf
|
|||
f4227fa121
|
|||
1510b16ceb
|
|||
fead7f1bff
|
|||
658e4aa5f2
|
|||
fc1962defc
|
|||
12c3ddc31d
|
|||
e4f6863e21
|
|||
8ee0748740
|
@ -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
3
.env
Normal 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
2
.env.development
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_URL="http://localhost:8000"
|
||||
VITE_REVOLUT_MODE="sandbox"
|
2
.env.lnvps
Normal file
2
.env.lnvps
Normal 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"
|
10
Dockerfile
10
Dockerfile
@ -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
4
custom.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module "*.md" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
10
nginx.conf
Normal file
10
nginx.conf
Normal file
@ -0,0 +1,10 @@
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html =404;
|
||||
}
|
||||
}
|
16
package.json
16
package.json
@ -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"
|
||||
}
|
||||
|
365
src/api.ts
365
src/api.ts
@ -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
71
src/blossom.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { base64, bytesToString } from "@scure/base";
|
||||
import { throwIfOffline, unixNow } from "@snort/shared";
|
||||
import { EventKind, EventPublisher } from "@snort/system";
|
||||
|
||||
export interface BlobDescriptor {
|
||||
url?: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
type?: string;
|
||||
uploaded?: number;
|
||||
}
|
||||
|
||||
export class Blossom {
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly publisher: EventPublisher,
|
||||
) {
|
||||
this.url = new URL(this.url).toString();
|
||||
}
|
||||
|
||||
async upload(file: File) {
|
||||
const hash = await window.crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
await file.arrayBuffer(),
|
||||
);
|
||||
const tags = [["x", bytesToString("hex", new Uint8Array(hash))]];
|
||||
|
||||
const rsp = await this.#req("/upload", "PUT", file, tags);
|
||||
if (rsp.ok) {
|
||||
return (await rsp.json()) as BlobDescriptor;
|
||||
} else {
|
||||
const text = await rsp.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
}
|
||||
|
||||
async #req(
|
||||
path: string,
|
||||
method: "GET" | "POST" | "DELETE" | "PUT",
|
||||
body?: BodyInit,
|
||||
tags?: Array<Array<string>>,
|
||||
) {
|
||||
throwIfOffline();
|
||||
|
||||
const url = `${this.url}upload`;
|
||||
const now = unixNow();
|
||||
const auth = async (url: string, method: string) => {
|
||||
const auth = await this.publisher.generic((eb) => {
|
||||
eb.kind(24_242 as EventKind)
|
||||
.tag(["u", url])
|
||||
.tag(["method", method])
|
||||
.tag(["t", path.slice(1)])
|
||||
.tag(["expiration", (now + 10).toString()]);
|
||||
tags?.forEach((t) => eb.tag(t));
|
||||
return eb;
|
||||
});
|
||||
return `Nostr ${base64.encode(
|
||||
new TextEncoder().encode(JSON.stringify(auth)),
|
||||
)}`;
|
||||
};
|
||||
|
||||
return await fetch(url, {
|
||||
method,
|
||||
body,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: await auth(url, method),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
63
src/components/account-domains.tsx
Normal file
63
src/components/account-domains.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
27
src/components/button-filter.tsx
Normal file
27
src/components/button-filter.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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} />
|
||||
|
24
src/components/latest-news.tsx
Normal file
24
src/components/latest-news.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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
195
src/components/markdown.tsx
Normal 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;
|
@ -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),
|
||||
|
26
src/components/news-link.tsx
Normal file
26
src/components/news-link.tsx
Normal 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>
|
||||
);
|
||||
}
|
40
src/components/nostr-domain-row.tsx
Normal file
40
src/components/nostr-domain-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
69
src/components/revolut.tsx
Normal file
69
src/components/revolut.tsx
Normal 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>;
|
||||
}
|
33
src/components/spinner.css
Normal file
33
src/components/spinner.css
Normal file
@ -0,0 +1,33 @@
|
||||
.spinner_V8m1 {
|
||||
transform-origin: center;
|
||||
animation: spinner_zKoa 2s linear infinite;
|
||||
}
|
||||
|
||||
.spinner_V8m1 circle {
|
||||
stroke-linecap: round;
|
||||
animation: spinner_YpZS 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spinner_zKoa {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinner_YpZS {
|
||||
0% {
|
||||
stroke-dasharray: 0 150;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
47.5% {
|
||||
stroke-dasharray: 42 150;
|
||||
stroke-dashoffset: -16;
|
||||
}
|
||||
|
||||
95%,
|
||||
100% {
|
||||
stroke-dasharray: 42 150;
|
||||
stroke-dashoffset: -59;
|
||||
}
|
||||
}
|
23
src/components/spinner.tsx
Normal file
23
src/components/spinner.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import "./spinner.css";
|
||||
|
||||
export interface IconProps {
|
||||
className?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const Spinner = (props: IconProps) => (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
{...props}
|
||||
>
|
||||
<g className="spinner_V8m1">
|
||||
<circle cx="10" cy="10" r="7.5" fill="none" strokeWidth="3"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default Spinner;
|
97
src/components/ssh-keys.tsx
Normal file
97
src/components/ssh-keys.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { UserSshKey } from "../api";
|
||||
import useLogin from "../hooks/login";
|
||||
import { AsyncButton } from "./button";
|
||||
|
||||
export default function SSHKeySelector({
|
||||
selectedKey,
|
||||
setSelectedKey,
|
||||
}: {
|
||||
selectedKey: UserSshKey["id"];
|
||||
setSelectedKey: (k: UserSshKey["id"]) => void;
|
||||
}) {
|
||||
const login = useLogin();
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [newKeyError, setNewKeyError] = useState("");
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [showAddKey, setShowAddKey] = useState(false);
|
||||
const [sshKeys, setSshKeys] = useState<Array<UserSshKey>>([]);
|
||||
|
||||
async function addNewKey() {
|
||||
if (!login?.api) return;
|
||||
setNewKeyError("");
|
||||
|
||||
try {
|
||||
const nk = await login?.api.addSshKey(newKeyName, newKey);
|
||||
setNewKey("");
|
||||
setNewKeyName("");
|
||||
setSelectedKey(nk.id);
|
||||
setShowAddKey(false);
|
||||
login?.api.listSshKeys().then((a) => setSshKeys(a));
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setNewKeyError(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!login?.api) return;
|
||||
login?.api.listSshKeys().then((a) => {
|
||||
setSshKeys(a);
|
||||
if (a.length > 0) {
|
||||
setSelectedKey(a[0].id);
|
||||
} else {
|
||||
setShowAddKey(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{sshKeys.length > 0 && (
|
||||
<>
|
||||
<b>Select SSH Key:</b>
|
||||
<select
|
||||
className="bg-neutral-900 p-2 rounded-xl"
|
||||
value={selectedKey}
|
||||
onChange={(e) => setSelectedKey(Number(e.target.value))}
|
||||
>
|
||||
{sshKeys.map((a) => (
|
||||
<option value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
{!showAddKey && sshKeys.length > 0 && (
|
||||
<AsyncButton onClick={() => setShowAddKey(true)}>
|
||||
Add new SSH key
|
||||
</AsyncButton>
|
||||
)}
|
||||
{(showAddKey || sshKeys.length === 0) && (
|
||||
<>
|
||||
<b>Add SSH Key:</b>
|
||||
<textarea
|
||||
rows={5}
|
||||
placeholder="ssh-[rsa|ed25519] AA== id"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Key name"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
/>
|
||||
<AsyncButton
|
||||
disabled={newKey.length < 10 || newKeyName.length < 2}
|
||||
onClick={addNewKey}
|
||||
>
|
||||
Add Key
|
||||
</AsyncButton>
|
||||
{newKeyError && <b className="text-red-500">{newKeyError}</b>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
137
src/components/vps-custom.tsx
Normal file
137
src/components/vps-custom.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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,
|
||||
|
@ -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],
|
||||
);
|
||||
}
|
||||
|
@ -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 |
@ -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;
|
||||
}
|
||||
|
135
src/login.ts
135
src/login.ts
@ -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();
|
||||
|
52
src/main.tsx
52
src/main.tsx
@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
126
src/pages/account-domain.tsx
Normal file
126
src/pages/account-domain.tsx
Normal 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"}>< 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>
|
||||
);
|
||||
}
|
153
src/pages/account-settings.tsx
Normal file
153
src/pages/account-settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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
25
src/pages/news-post.tsx
Normal 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
35
src/pages/news.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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
174
src/pages/sign-up.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import {
|
||||
EventPublisher,
|
||||
Nip46Signer,
|
||||
Nip7Signer,
|
||||
PrivateKeySigner,
|
||||
} from "@snort/system";
|
||||
import { AsyncButton } from "../components/button";
|
||||
import { useContext, useState } from "react";
|
||||
import { bech32ToHex, hexToBech32 } from "@snort/shared";
|
||||
import { openFile } from "../utils";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { Blossom } from "../blossom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LoginState } from "../login";
|
||||
|
||||
export default function SignUpPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [keyIn, setKeyIn] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [file, setFile] = useState<File>();
|
||||
const [key, setKey] = useState<PrivateKeySigner>();
|
||||
const system = useContext(SnortContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function uploadImage() {
|
||||
const f = await openFile();
|
||||
setFile(f);
|
||||
}
|
||||
|
||||
async function spawnAccount() {
|
||||
if (!key) return;
|
||||
setError("");
|
||||
const pub = new EventPublisher(key, key.getPubKey());
|
||||
|
||||
let pic = undefined;
|
||||
if (file) {
|
||||
// upload picture
|
||||
const b = new Blossom("https://nostr.download", pub);
|
||||
const up = await b.upload(file);
|
||||
if (up.url) {
|
||||
pic = up.url;
|
||||
} else {
|
||||
setError("Upload filed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ev = await pub.metadata({
|
||||
name: name,
|
||||
picture: pic,
|
||||
});
|
||||
system.BroadcastEvent(ev);
|
||||
LoginState.loginPrivateKey(key.privateKey);
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
async function loginKey() {
|
||||
setError("");
|
||||
try {
|
||||
if (keyIn.startsWith("nsec1")) {
|
||||
LoginState.loginPrivateKey(bech32ToHex(keyIn));
|
||||
navigate("/");
|
||||
} else if (keyIn.startsWith("bunker://")) {
|
||||
const signer = new Nip46Signer(keyIn);
|
||||
await signer.init();
|
||||
const pubkey = await signer.getPubKey();
|
||||
LoginState.loginBunker(keyIn, signer.privateKey!, pubkey);
|
||||
navigate("/");
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <b className="text-red-500">{error}</b>}
|
||||
<h1>Login</h1>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="nsec/bunker"
|
||||
value={keyIn}
|
||||
onChange={(e) => setKeyIn(e.target.value)}
|
||||
/>
|
||||
<AsyncButton
|
||||
onClick={loginKey}
|
||||
disabled={!keyIn.startsWith("nsec") && !keyIn.startsWith("bunker://")}
|
||||
>
|
||||
Login
|
||||
</AsyncButton>
|
||||
{window.nostr && (
|
||||
<div className="flex flex-col gap-4">
|
||||
Browser Extension:
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
const pk = await new Nip7Signer().getPubKey();
|
||||
LoginState.login(pk);
|
||||
navigate("/");
|
||||
}}
|
||||
>
|
||||
Nostr Extension
|
||||
</AsyncButton>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-4 items-center my-6">
|
||||
<div className="text-xl">OR</div>
|
||||
<div className="h-[1px] bg-neutral-800 w-full"></div>
|
||||
</div>
|
||||
|
||||
<h1>Create Account</h1>
|
||||
|
||||
<p>
|
||||
LNVPS uses nostr accounts,{" "}
|
||||
<a
|
||||
href="https://nostr.how/en/what-is-nostr"
|
||||
target="_blank"
|
||||
className="underline"
|
||||
>
|
||||
what is nostr?
|
||||
</a>
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>Avatar</div>
|
||||
<div
|
||||
className="w-40 h-40 bg-neutral-900 rounded-xl relative cursor-pointer overflow-hidden"
|
||||
onClick={uploadImage}
|
||||
>
|
||||
<div className="absolute bg-black/50 w-full h-full hover:opacity-90 opacity-0 flex items-center justify-center">
|
||||
Upload
|
||||
</div>
|
||||
{file && (
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>Name</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Display name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
setKey(PrivateKeySigner.random());
|
||||
}}
|
||||
>
|
||||
Create Account
|
||||
</AsyncButton>
|
||||
|
||||
{key && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3>Your new key:</h3>
|
||||
<div className="font-monospace select-all">
|
||||
{hexToBech32("nsec", key.privateKey)}
|
||||
</div>
|
||||
<b>Please save this key, it CANNOT be recovered</b>
|
||||
</div>
|
||||
<AsyncButton onClick={spawnAccount}>Login</AsyncButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
56
src/pages/status.tsx
Normal file
56
src/pages/status.tsx
Normal 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
6
src/pages/terms.tsx
Normal 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
287
src/pages/vm-billing.tsx
Normal 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}>
|
||||
< 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
57
src/pages/vm-console.tsx
Normal 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
185
src/pages/vm-graphs.tsx
Normal 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}>
|
||||
< 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>
|
||||
);
|
||||
}
|
232
src/pages/vm.tsx
232
src/pages/vm.tsx
@ -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"}>< 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
37
src/ref.ts
Normal 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
11
src/status.json
Normal 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
126
src/tos.md
Normal 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
79
src/utils.ts
Normal 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" : "");
|
||||
}
|
@ -2,7 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
@ -4,4 +4,5 @@ import react from "@vitejs/plugin-react";
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
assetsInclude: ["**/*.md"],
|
||||
});
|
||||
|
Reference in New Issue
Block a user