Compare commits
40 Commits
912bb21022
...
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
|
@ -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)
|
||||
|
4
.env
4
.env
@ -1 +1,3 @@
|
||||
VITE_API_URL="https://api.lnvps.net"
|
||||
VITE_API_URL="https://api.lnvps.net"
|
||||
VITE_FOOTER_NOTE_1=""
|
||||
VITE_FOOTER_NOTE_2=""
|
@ -1 +1,2 @@
|
||||
VITE_API_URL="http://localhost:8000"
|
||||
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"
|
@ -1,9 +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 nginx.conf /etc/nginx/conf.d/default.conf
|
6
custom.d.ts
vendored
6
custom.d.ts
vendored
@ -1,4 +1,4 @@
|
||||
declare module "*.md" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@revolut/checkout": "^1.1.20",
|
||||
"@scure/base": "^1.2.1",
|
||||
"@snort/shared": "^1.0.17",
|
||||
"@snort/system": "^1.6.1",
|
||||
@ -19,11 +20,13 @@
|
||||
"@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",
|
||||
@ -40,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"
|
||||
}
|
||||
|
250
src/api.ts
250
src/api.ts
@ -9,17 +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;
|
||||
amount: number;
|
||||
currency: "EUR" | "BTC";
|
||||
currency: string;
|
||||
interval_amount: number;
|
||||
interval_type: string;
|
||||
}
|
||||
@ -29,16 +48,54 @@ export interface VmHostRegion {
|
||||
name: string;
|
||||
}
|
||||
|
||||
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;
|
||||
created: Date;
|
||||
expires?: Date;
|
||||
cpu: number;
|
||||
memory: number;
|
||||
disk_size: number;
|
||||
disk_type: string;
|
||||
disk_interface: string;
|
||||
disk_type: DiskType;
|
||||
disk_interface: DiskInterface;
|
||||
cost_plan: VmCostPlan;
|
||||
region: VmHostRegion;
|
||||
}
|
||||
@ -57,7 +114,9 @@ export interface VmStatus {
|
||||
export interface VmIpAssignment {
|
||||
id: number;
|
||||
ip: string;
|
||||
range: string;
|
||||
gateway: string;
|
||||
forward_dns?: string;
|
||||
reverse_dns?: string;
|
||||
}
|
||||
|
||||
export interface VmInstance {
|
||||
@ -78,6 +137,7 @@ export interface VmOsImage {
|
||||
flavour: string;
|
||||
version: string;
|
||||
release_date: string;
|
||||
default_username?: string;
|
||||
}
|
||||
|
||||
export interface UserSshKey {
|
||||
@ -87,22 +147,70 @@ 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>>(
|
||||
@ -132,6 +240,13 @@ export class LNVpsApi {
|
||||
return data;
|
||||
}
|
||||
|
||||
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),
|
||||
@ -153,13 +268,20 @@ export class LNVpsApi {
|
||||
return data;
|
||||
}
|
||||
|
||||
async listOffers() {
|
||||
const { data } = await this.#handleResponse<ApiResponse<Array<VmTemplate>>>(
|
||||
await this.#req("/api/v1/vm/templates", "GET"),
|
||||
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"),
|
||||
@ -184,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;
|
||||
}
|
||||
@ -209,14 +361,36 @@ export class LNVpsApi {
|
||||
return data;
|
||||
}
|
||||
|
||||
async connect_terminal(id: number) {
|
||||
const u = `${this.url}/api/v1/console/${id}`;
|
||||
async invoiceLink(id: string) {
|
||||
const u = `${this.url}/api/v1/payment/${id}/invoice`;
|
||||
const auth = await this.#auth_event(u, "GET");
|
||||
const ws = new WebSocket(
|
||||
`${u}?auth=${base64.encode(
|
||||
new TextEncoder().encode(JSON.stringify(auth)),
|
||||
)}`,
|
||||
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);
|
||||
@ -227,6 +401,46 @@ export class LNVpsApi {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
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>
|
||||
);
|
||||
}
|
@ -22,7 +22,7 @@ const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>(
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
"py-1 px-2 rounded-xl font-medium relative",
|
||||
"py-2 px-3 rounded-xl font-medium relative",
|
||||
{
|
||||
"bg-neutral-800 cursor-not-allowed text-neutral-500":
|
||||
!hasBg && props.disabled === true,
|
||||
|
@ -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,8 @@
|
||||
import classNames from "classnames";
|
||||
import { MouseEventHandler } from "react";
|
||||
|
||||
import Icons from "../icons.svg?no-inline";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
size?: number;
|
||||
@ -10,7 +12,7 @@ type Props = {
|
||||
|
||||
export function Icon(props: Props) {
|
||||
const size = props.size || 20;
|
||||
const href = `/icons.svg#${props.name}`;
|
||||
const href = `${Icons}#${props.name}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
|
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,39 +0,0 @@
|
||||
.markdown a {
|
||||
@apply underline;
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
margin: 0;
|
||||
padding-left: 12px;
|
||||
@apply border-l-neutral-800 border-2 text-neutral-400;
|
||||
}
|
||||
|
||||
.markdown hr {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
margin: 20px;
|
||||
@apply bg-neutral-800;
|
||||
}
|
||||
|
||||
.markdown img:not(.custom-emoji),
|
||||
.markdown video,
|
||||
.markdown iframe,
|
||||
.markdown audio {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.markdown iframe,
|
||||
.markdown video {
|
||||
width: -webkit-fill-available;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.markdown h1,
|
||||
.markdown h2,
|
||||
.markdown h3,
|
||||
.markdown h4,
|
||||
.markdown h5,
|
||||
.markdown h6 {
|
||||
margin: 0.5em 0;
|
||||
}
|
@ -1,134 +1,195 @@
|
||||
import "./markdown.css";
|
||||
|
||||
import { ReactNode, forwardRef, useMemo } from "react";
|
||||
import { Token, Tokens, marked } from "marked";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const Markdown = forwardRef<HTMLDivElement, MarkdownProps>((props: MarkdownProps, ref) => {
|
||||
const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
|
||||
(props: MarkdownProps, ref) => {
|
||||
let ctr = 0;
|
||||
function renderToken(t: Token): ReactNode {
|
||||
try {
|
||||
switch (t.type) {
|
||||
case "paragraph": {
|
||||
return <div key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</div>;
|
||||
}
|
||||
case "image": {
|
||||
return <img key={ctr++} src={t.href} />;
|
||||
}
|
||||
case "heading": {
|
||||
switch (t.depth) {
|
||||
case 1:
|
||||
return <h1 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h1>;
|
||||
case 2:
|
||||
return <h2 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h2>;
|
||||
case 3:
|
||||
return <h3 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h3>;
|
||||
case 4:
|
||||
return <h4 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h4>;
|
||||
case 5:
|
||||
return <h5 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h5>;
|
||||
case 6:
|
||||
return <h6 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h6>;
|
||||
}
|
||||
throw new Error("Invalid heading");
|
||||
}
|
||||
case "codespan": {
|
||||
return <code key={ctr++}>{t.raw}</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++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</blockquote>;
|
||||
}
|
||||
case "link": {
|
||||
return (
|
||||
<Link to={t.href} key={ctr++}>
|
||||
{t.tokens ? t.tokens.map(renderToken) : t.raw}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
case "list": {
|
||||
if (t.ordered) {
|
||||
return <ol key={ctr++}>{t.items.map(renderToken)}</ol>;
|
||||
} else {
|
||||
return <ul key={ctr++}>{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}`);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
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);
|
||||
return marked.lexer(props.content);
|
||||
}, [props.content]);
|
||||
return (
|
||||
<div className="markdown" ref={ref}>
|
||||
{parsed.filter(a => a.type !== "footnote" && a.type !== "footnotes").map(a => renderToken(a))}
|
||||
</div>
|
||||
<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-neutral-700 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,12 +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 (!login) {
|
||||
return placeholder("Please Login");
|
||||
return (
|
||||
<AsyncButton
|
||||
className={`${classNames} bg-red-900`}
|
||||
onClick={() => navigte("/login")}
|
||||
>
|
||||
Login To Order
|
||||
</AsyncButton>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AsyncButton
|
||||
|
@ -2,7 +2,13 @@ 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 (
|
||||
@ -11,9 +17,11 @@ export default function Profile({ link }: { link: NostrLink }) {
|
||||
src={profile?.picture}
|
||||
className="w-12 h-12 rounded-full bg-neutral-800 object-cover object-center"
|
||||
/>
|
||||
<div>
|
||||
{name.length > 0 ? 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>;
|
||||
}
|
@ -18,6 +18,7 @@ export default function VmActions({
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-2">
|
||||
<AsyncButton
|
||||
title={state === "running" ? "Stop VM" : "Start VM"}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@ -40,15 +41,24 @@ export default function VmActions({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
<Icon
|
||||
name="refresh-1"
|
||||
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
|
||||
size={40}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>*/}
|
||||
<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>
|
||||
);
|
||||
}
|
@ -41,7 +41,12 @@ export default function VpsInstanceRow({
|
||||
<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>
|
||||
</>
|
||||
|
@ -11,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 {
|
||||
@ -35,7 +36,7 @@ export default function VpsPayment({
|
||||
}
|
||||
}, 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">
|
||||
@ -47,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>
|
||||
);
|
||||
}
|
||||
|
@ -3,16 +3,15 @@ import BytesSize from "./bytes";
|
||||
|
||||
export default function VpsResources({ vm }: { vm: VmInstance | VmTemplate }) {
|
||||
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 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;
|
||||
const template = "template" in vm ? vm.template : (vm as VmTemplate);
|
||||
return (
|
||||
<>
|
||||
<div className="text-xs text-neutral-400">
|
||||
{template?.cpu} vCPU, <BytesSize value={template?.memory ?? 0} /> RAM,{" "}
|
||||
<BytesSize value={template?.disk_size ?? 0} /> {diskType?.toUpperCase()},{" "}
|
||||
{region && <>Location: {region}</>}
|
||||
<BytesSize value={template?.disk_size ?? 0} /> {diskType?.toUpperCase()}
|
||||
, {region && <>Location: {region}</>}
|
||||
</div>
|
||||
{status && status.state === "running" && (
|
||||
<div className="text-sm text-neutral-200">
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useContext, useMemo, useSyncExternalStore } from "react";
|
||||
import { LoginState } from "../login";
|
||||
import { LoginSession, LoginState } from "../login";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { LNVpsApi } from "../api";
|
||||
import { ApiUrl } from "../const";
|
||||
@ -10,12 +10,20 @@ export default function useLogin() {
|
||||
() => LoginState.snapshot(),
|
||||
);
|
||||
const system = useContext(SnortContext);
|
||||
return useMemo(() => session
|
||||
? {
|
||||
type: session.type,
|
||||
publicKey: session.publicKey,
|
||||
system,
|
||||
api: new LNVpsApi(ApiUrl, LoginState.getSigner()),
|
||||
}
|
||||
: undefined, [session, system]);
|
||||
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,25 +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>
|
||||
<svg id="pencil" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/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"/>
|
||||
</svg>
|
||||
|
||||
</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: 3.5 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;
|
||||
}
|
||||
|
||||
@ -44,4 +45,4 @@ select {
|
||||
|
||||
input:disabled {
|
||||
@apply text-neutral-200/50;
|
||||
}
|
||||
}
|
||||
|
11
src/login.ts
11
src/login.ts
@ -11,6 +11,7 @@ export interface LoginSession {
|
||||
publicKey: string;
|
||||
privateKey?: string;
|
||||
bunker?: string;
|
||||
currency: string;
|
||||
}
|
||||
class LoginStore extends ExternalStore<LoginSession | undefined> {
|
||||
#session?: LoginSession;
|
||||
@ -42,6 +43,7 @@ class LoginStore extends ExternalStore<LoginSession | undefined> {
|
||||
this.#session = {
|
||||
type: type ?? "nip7",
|
||||
publicKey: pubkey,
|
||||
currency: "EUR",
|
||||
};
|
||||
this.#save();
|
||||
}
|
||||
@ -52,6 +54,7 @@ class LoginStore extends ExternalStore<LoginSession | undefined> {
|
||||
type: "nsec",
|
||||
publicKey: s.getPubKey(),
|
||||
privateKey: key,
|
||||
currency: "EUR",
|
||||
};
|
||||
this.#save();
|
||||
}
|
||||
@ -62,6 +65,7 @@ class LoginStore extends ExternalStore<LoginSession | undefined> {
|
||||
publicKey: remotePubkey,
|
||||
privateKey: localKey,
|
||||
bunker: url,
|
||||
currency: "EUR",
|
||||
};
|
||||
this.#save();
|
||||
}
|
||||
@ -99,6 +103,13 @@ class LoginStore extends ExternalStore<LoginSession | undefined> {
|
||||
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));
|
||||
|
44
src/main.tsx
44
src/main.tsx
@ -11,6 +11,14 @@ 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,
|
||||
@ -39,18 +47,50 @@ const router = createBrowserRouter([
|
||||
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,15 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AccountDetail, LNVpsApi, VmInstance } from "../api";
|
||||
import { LNVpsApi, VmInstance } from "../api";
|
||||
import useLogin from "../hooks/login";
|
||||
import VpsInstanceRow from "../components/vps-instance";
|
||||
import { hexToBech32 } from "@snort/shared";
|
||||
import { Icon } from "../components/icon";
|
||||
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 [acc, setAcc] = useState<AccountDetail>();
|
||||
const [editEmail, setEditEmail] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [vms, setVms] = useState<Array<VmInstance>>([]);
|
||||
|
||||
async function loadVms(api: LNVpsApi) {
|
||||
@ -20,7 +20,6 @@ export default function AccountPage() {
|
||||
useEffect(() => {
|
||||
if (login?.api) {
|
||||
loadVms(login.api);
|
||||
login.api.getAccount().then(setAcc);
|
||||
const t = setInterval(() => {
|
||||
loadVms(login.api);
|
||||
}, 5_000);
|
||||
@ -28,64 +27,51 @@ export default function AccountPage() {
|
||||
}
|
||||
}, [login]);
|
||||
|
||||
function notifications() {
|
||||
return <>
|
||||
<h3>Notification Settings</h3>
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
|
||||
const npub = hexToBech32("npub", login?.publicKey);
|
||||
const subjectLine = `[${npub}] Account Query`;
|
||||
return (
|
||||
<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>
|
||||
{notifications()}
|
||||
<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">
|
||||
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);
|
||||
}
|
||||
}} />
|
||||
<VpsInstanceRow
|
||||
key={a.id}
|
||||
vm={a}
|
||||
onReload={() => {
|
||||
if (login?.api) {
|
||||
loadVms(login.api);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<AccountNostrDomains />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,55 +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-6">
|
||||
<a target="_blank" href={`https://snort.social/${NostrProfile.encode()}`}>
|
||||
<Profile link={NostrProfile} />
|
||||
</a>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="text-center">
|
||||
<a target="_blank" href="http://speedtest.v0l.io">
|
||||
Speedtest
|
||||
</a>
|
||||
{" | "}
|
||||
<a href="/lnvps.asc">PGP</a>
|
||||
{" | "}
|
||||
<a href="https://lnvps1.statuspage.io/" target="_blank">Status</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>
|
||||
</div>
|
||||
<div className="text-xs text-center text-neutral-400">
|
||||
LNVPS is a trading name of Apex Strata Ltd, a company registered in Ireland.
|
||||
<br />
|
||||
Comany Number: 702423,
|
||||
Address: Suite 10628, 26/27 Upper Pembroke Street, Dublin 2, D02 X361, Ireland
|
||||
{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>
|
||||
);
|
||||
}
|
@ -8,6 +8,7 @@ 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();
|
||||
@ -29,8 +30,24 @@ export default function OrderPage() {
|
||||
|
||||
setOrderError("");
|
||||
try {
|
||||
const newVm = await login.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) {
|
||||
|
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>
|
||||
);
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import Markdown from "../components/markdown";
|
||||
import TOS from "../../tos.md?raw";
|
||||
import TOS from "../tos.md?raw";
|
||||
|
||||
export function TosPage() {
|
||||
return <Markdown content={TOS} />
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
271
src/pages/vm.tsx
271
src/pages/vm.tsx
@ -1,160 +1,189 @@
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { VmInstance, VmPayment } from "../api";
|
||||
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 { useCallback, useEffect, useRef, 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 { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { toEui64 } from "../utils";
|
||||
import { Icon } from "../components/icon";
|
||||
import Modal from "../components/modal";
|
||||
import SSHKeySelector from "../components/ssh-keys";
|
||||
|
||||
const fit = new FitAddon();
|
||||
|
||||
export default function VmPage() {
|
||||
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 [term] = useState<Terminal>();
|
||||
const termRef = useRef<HTMLDivElement | null>(null);
|
||||
const [state, setState] = useState<VmInstance | undefined>(location?.state);
|
||||
|
||||
const [editKey, setEditKey] = useState(false);
|
||||
const [key, setKey] = useState(state?.ssh_key.id ?? -1);
|
||||
const [editReverse, setEditReverse] = useState<VmIpAssignment>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [key, setKey] = useState(state?.ssh_key?.id ?? -1);
|
||||
|
||||
const renew = useCallback(
|
||||
async function () {
|
||||
if (!login?.api || !state) return;
|
||||
const p = await login?.api.renewVm(state.id);
|
||||
setPayment(p);
|
||||
},
|
||||
[login?.api, state],
|
||||
);
|
||||
async function reloadVmState() {
|
||||
if (!state) return;
|
||||
const newState = await login?.api.getVm(state.id);
|
||||
setState(newState);
|
||||
}
|
||||
|
||||
/*async function openTerminal() {
|
||||
if (!login?.api || !state) return;
|
||||
const ws = await login.api.connect_terminal(state.id);
|
||||
const te = new Terminal();
|
||||
const webgl = new WebglAddon();
|
||||
webgl.onContextLoss(() => {
|
||||
webgl.dispose();
|
||||
});
|
||||
te.loadAddon(webgl);
|
||||
te.loadAddon(fit);
|
||||
te.onResize(({ cols, rows }) => {
|
||||
ws.send(`${cols}:${rows}`);
|
||||
});
|
||||
const attach = new AttachAddon(ws);
|
||||
te.loadAddon(attach);
|
||||
setTerm(te);
|
||||
}*/
|
||||
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(() => {
|
||||
if (term && termRef.current) {
|
||||
term.open(termRef.current);
|
||||
term.focus();
|
||||
fit.fit();
|
||||
}
|
||||
}, [termRef, term, fit]);
|
||||
const t = setInterval(() => reloadVmState(), 5000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
switch (action) {
|
||||
case "renew":
|
||||
renew();
|
||||
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">
|
||||
<Link to={"/account"}>< Back</Link>
|
||||
<VpsInstanceRow vm={state} actions={true} />
|
||||
{action === undefined && (
|
||||
<>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="text-xl">Network:</div>
|
||||
{(state.ip_assignments?.length ?? 0) === 0 && (
|
||||
<div className="text-sm text-red-500">No IP's assigned</div>
|
||||
)}
|
||||
{state.ip_assignments?.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="text-sm bg-neutral-900 px-3 py-1 rounded-lg"
|
||||
>
|
||||
{a.ip.split("/")[0]}
|
||||
</div>
|
||||
))}
|
||||
<div className="text-sm bg-neutral-900 px-3 py-1 rounded-lg">
|
||||
{toEui64("2a13:2c0::", state.mac_address)}
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="text-xl">SSH Key:</div>
|
||||
<div className="text-sm bg-neutral-900 px-3 py-1 rounded-lg">
|
||||
{state.ssh_key?.name}
|
||||
</div>
|
||||
<Icon name="pencil" onClick={() => setEditKey(true)} />
|
||||
<Icon name="pencil" onClick={() => setEditKey(true)} />
|
||||
</div>
|
||||
{!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>
|
||||
<hr />
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<AsyncButton onClick={() => navigate("/vm/renew", { state })}>
|
||||
Extend Now
|
||||
</AsyncButton>
|
||||
{/*
|
||||
{!term && <AsyncButton onClick={openTerminal}>Connect Terminal</AsyncButton>}
|
||||
{term && <div className="border p-2" ref={termRef}></div>}*/}
|
||||
</>
|
||||
)}
|
||||
{action === "renew" && (
|
||||
<>
|
||||
<h3>Renew VPS</h3>
|
||||
{payment && (
|
||||
<VpsPayment
|
||||
payment={payment}
|
||||
onPaid={async () => {
|
||||
if (!login?.api || !state) return;
|
||||
const newState = await login?.api.getVm(state.id);
|
||||
navigate("/vm", {
|
||||
state: newState,
|
||||
});
|
||||
setPayment(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<hr />
|
||||
<div className="flex gap-4">
|
||||
{/*<AsyncButton onClick={() => navigate("/vm/console", { state })}>
|
||||
Console
|
||||
</AsyncButton>*/}
|
||||
<AsyncButton onClick={() => navigate("/vm/billing", { state })}>
|
||||
Billing
|
||||
</AsyncButton>
|
||||
<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;
|
||||
await login.api.patchVm(state.id, {
|
||||
ssh_key_id: key,
|
||||
});
|
||||
const ns = await login.api.getVm(state?.id);
|
||||
navigate(".", {
|
||||
state: ns,
|
||||
replace: true,
|
||||
});
|
||||
setEditKey(false);
|
||||
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
|
||||
|
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"
|
||||
}
|
||||
]
|
||||
}
|
@ -1,107 +1,126 @@
|
||||
# Terms of Service
|
||||
# Terms of Service
|
||||
|
||||
**LNVPS**
|
||||
*Last Updated: February 26, 2025*
|
||||
_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.
|
||||
## 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.
|
||||
## 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
|
||||
## 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
|
||||
## 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.
|
||||
- **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.
|
||||
## 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.
|
||||
## 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).
|
||||
## 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.
|
||||
- **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.
|
||||
## 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).
|
||||
## 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).
|
||||
## 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.
|
||||
## 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.
|
||||
## 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
|
||||
## 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:
|
||||
## 14. Contact Us
|
||||
|
||||
For questions or support:
|
||||
|
||||
- **Email**: sales@lnvps.net
|
||||
- **Address**: Suite 10628, 26/27 Upper Pembroke Street, Dublin 2, D02 X361, Ireland
|
||||
|
||||
---
|
||||
---
|
26
src/utils.ts
26
src/utils.ts
@ -51,3 +51,29 @@ export function toEui64(prefix: string, mac: string) {
|
||||
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,
|
||||
|
||||
|
Reference in New Issue
Block a user