Compare commits

...

40 Commits

Author SHA1 Message Date
0f8ee33279 feat: show lnurl as payment method
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-02 13:59:37 +01:00
f28c785cbd feat: show default username for ssh
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-02 09:50:10 +01:00
e4bee1a568 fix: icons
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-01 17:59:59 +01:00
51d7cea581 feat: navigate invoice
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-01 17:47:00 +01:00
9a04548627 feat: payment history 2025-05-01 15:32:00 +01:00
93de2704cc feat: billing info
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-01 14:51:20 +01:00
d2ee1acae7 feat: nostr domain
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-03 12:56:55 +01:00
1834242198 fix: revolut order amount
All checks were successful
continuous-integration/drone/push Build is passing
closes #21
2025-04-02 10:33:21 +01:00
5176386849 feat: filter disk type on templates
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-02 09:56:00 +01:00
db66cd4dc3 feat: filter custom order disk types
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-01 11:40:36 +01:00
74acc4ee42 fix: fotter notes
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-28 14:48:15 +00:00
9d70de9b8a feat: currency selector
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-28 14:29:00 +00:00
c67dd4c793 fix: build
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-28 12:26:54 +00:00
63c737b160 feat: remove hard-coded ip6 addr
Some checks failed
continuous-integration/drone/push Build is failing
2025-03-28 10:31:21 +00:00
a3836f445e fix: build
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-26 16:55:22 +00:00
0042a706bc fix: pcie
Some checks failed
continuous-integration/drone/push Build is failing
2025-03-24 16:15:08 +00:00
3c3218044b feat: filter regions 2025-03-24 16:10:42 +00:00
c0b7836ce3 feat: console progress
Some checks failed
continuous-integration/drone/push Build is failing
2025-03-24 13:04:55 +00:00
1f38e22053 feat: re-install
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-20 12:31:17 +00:00
26d36adbeb feat: taxes
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
ref: LNVPS/api#18
2025-03-11 15:59:32 +00:00
c1312d97f1 feat: revolut pay
All checks were successful
continuous-integration/drone/push Build is passing
ref: LNVPS/api#24
2025-03-11 12:42:16 +00:00
57cc619b8c feat: alt prices
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-10 15:10:47 +00:00
7cba506d6b feat: latest news on homepage
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-06 22:32:09 +00:00
8e3e4c0364 feat: custom pricing
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-06 21:43:31 +00:00
1aab7c9372 feat: news page
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-06 11:00:05 +00:00
0b93b0d4f9 fix: graph label
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-05 16:37:14 +00:00
c6e4a9e3c9 chore: formatting
Some checks failed
continuous-integration/drone/push Build is failing
2025-03-05 16:26:47 +00:00
7ba2659fbf feat: graphs 2025-03-05 16:26:04 +00:00
b52735a0a4 feat: new billing page 2025-03-05 15:33:32 +00:00
7bdea28bc9 fix: show no offers message
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-05 14:15:08 +00:00
d05c69af9c feat: ref codes
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-05 11:10:28 +00:00
c5d45b0843 fix: state reload 2025-03-05 10:59:49 +00:00
072e791d2c feat: reload vm state
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-05 10:21:47 +00:00
669b852106 feat: edit reverse dns
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-04 14:29:09 +00:00
cea6beee73 feat: show invoice string
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-01 21:46:00 +00:00
5b3ff37ca0 chore: use github link
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-28 12:11:21 +00:00
43886867e3 feat: login to order
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-28 09:52:01 +00:00
88c8574966 fix: speedtest link
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-26 11:57:13 +00:00
13353251ed chore: formatting
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-26 11:54:32 +00:00
7af41a1480 feat: custom status page
feat: logout
2025-02-26 11:54:10 +00:00
53 changed files with 3152 additions and 689 deletions

View File

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

2
.env
View File

@ -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=""

View File

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

2
.env.lnvps Normal file
View File

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

View File

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

4
custom.d.ts vendored
View File

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

View File

@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@revolut/checkout": "^1.1.20",
"@scure/base": "^1.2.1", "@scure/base": "^1.2.1",
"@snort/shared": "^1.0.17", "@snort/shared": "^1.0.17",
"@snort/system": "^1.6.1", "@snort/system": "^1.6.1",
@ -19,11 +20,13 @@
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"iso-3166-1": "^2.1.1",
"marked": "^15.0.7", "marked": "^15.0.7",
"qr-code-styling": "^1.8.4", "qr-code-styling": "^1.8.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^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": { "devDependencies": {
"@eslint/js": "^9.8.0", "@eslint/js": "^9.8.0",
@ -40,7 +43,7 @@
"tailwindcss": "^3.4.8", "tailwindcss": "^3.4.8",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.0.0", "typescript-eslint": "^8.0.0",
"vite": "^5.4.0" "vite": "^6.3.4"
}, },
"packageManager": "yarn@4.4.0" "packageManager": "yarn@4.4.0"
} }

View File

@ -9,17 +9,36 @@ export type ApiResponse<T> = ApiResponseBase & {
data: T; data: T;
}; };
export enum DiskType {
SSD = "ssd",
HDD = "hdd",
}
export enum DiskInterface {
SATA = "sata",
SCSI = "scsi",
PCIe = "pcie",
}
export interface AccountDetail { export interface AccountDetail {
email?: string; email?: string;
contact_nip17: boolean; contact_nip17: boolean;
contact_email: 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 { export interface VmCostPlan {
id: number; id: number;
name: string; name: string;
amount: number; amount: number;
currency: "EUR" | "BTC"; currency: string;
interval_amount: number; interval_amount: number;
interval_type: string; interval_type: string;
} }
@ -29,16 +48,54 @@ export interface VmHostRegion {
name: string; 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 { export interface VmTemplate {
id: number; id: number;
pricing_id?: number;
name: string; name: string;
created: Date; created: Date;
expires?: Date; expires?: Date;
cpu: number; cpu: number;
memory: number; memory: number;
disk_size: number; disk_size: number;
disk_type: string; disk_type: DiskType;
disk_interface: string; disk_interface: DiskInterface;
cost_plan: VmCostPlan; cost_plan: VmCostPlan;
region: VmHostRegion; region: VmHostRegion;
} }
@ -57,7 +114,9 @@ export interface VmStatus {
export interface VmIpAssignment { export interface VmIpAssignment {
id: number; id: number;
ip: string; ip: string;
range: string; gateway: string;
forward_dns?: string;
reverse_dns?: string;
} }
export interface VmInstance { export interface VmInstance {
@ -78,6 +137,7 @@ export interface VmOsImage {
flavour: string; flavour: string;
version: string; version: string;
release_date: string; release_date: string;
default_username?: string;
} }
export interface UserSshKey { export interface UserSshKey {
@ -87,22 +147,70 @@ export interface UserSshKey {
export interface VmPayment { export interface VmPayment {
id: string; id: string;
invoice: string;
created: string; created: string;
expires: string; expires: string;
amount: number; amount: number;
currency: string;
tax: number;
is_paid: boolean; is_paid: boolean;
time: number;
data: {
lightning?: string;
revolut?: {
token: string;
};
};
} }
export interface PatchVm { export interface PatchVm {
ssh_key_id?: number; 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 { export class LNVpsApi {
constructor( constructor(
readonly url: string, readonly url: string,
readonly publisher: EventPublisher | undefined, readonly publisher: EventPublisher | undefined,
) { } ) {}
async getAccount() { async getAccount() {
const { data } = await this.#handleResponse<ApiResponse<AccountDetail>>( const { data } = await this.#handleResponse<ApiResponse<AccountDetail>>(
@ -132,6 +240,13 @@ export class LNVpsApi {
return data; 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) { async patchVm(id: number, req: PatchVm) {
const { data } = await this.#handleResponse<ApiResponse<void>>( const { data } = await this.#handleResponse<ApiResponse<void>>(
await this.#req(`/api/v1/vm/${id}`, "PATCH", req), await this.#req(`/api/v1/vm/${id}`, "PATCH", req),
@ -153,13 +268,20 @@ export class LNVpsApi {
return data; return data;
} }
async listOffers() { async reisntallVm(id: number) {
const { data } = await this.#handleResponse<ApiResponse<Array<VmTemplate>>>( const { data } = await this.#handleResponse<ApiResponse<VmInstance>>(
await this.#req("/api/v1/vm/templates", "GET"), await this.#req(`/api/v1/vm/${id}/re-install`, "PATCH"),
); );
return data; return data;
} }
async listOffers() {
const { data } = await this.#handleResponse<
ApiResponse<VmTemplateResponse>
>(await this.#req("/api/v1/vm/templates", "GET"));
return data;
}
async listOsImages() { async listOsImages() {
const { data } = await this.#handleResponse<ApiResponse<Array<VmOsImage>>>( const { data } = await this.#handleResponse<ApiResponse<Array<VmOsImage>>>(
await this.#req("/api/v1/image", "GET"), await this.#req("/api/v1/image", "GET"),
@ -184,20 +306,50 @@ export class LNVpsApi {
return data; 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>>( const { data } = await this.#handleResponse<ApiResponse<VmInstance>>(
await this.#req("/api/v1/vm", "POST", { await this.#req("/api/v1/vm", "POST", {
template_id, template_id,
image_id, image_id,
ssh_key_id, ssh_key_id,
ref_code,
}), }),
); );
return data; 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>>( 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; return data;
} }
@ -209,14 +361,36 @@ export class LNVpsApi {
return data; return data;
} }
async connect_terminal(id: number) { async invoiceLink(id: string) {
const u = `${this.url}/api/v1/console/${id}`; const u = `${this.url}/api/v1/payment/${id}/invoice`;
const auth = await this.#auth_event(u, "GET"); const auth = await this.#auth_event(u, "GET");
const ws = new WebSocket( const auth_b64 = base64.encode(
`${u}?auth=${base64.encode( new TextEncoder().encode(JSON.stringify(auth)),
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) => { return await new Promise<WebSocket>((resolve, reject) => {
ws.onopen = () => { ws.onopen = () => {
resolve(ws); 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) { async #handleResponse<T extends ApiResponseBase>(rsp: Response) {
if (rsp.ok) { if (rsp.ok) {
return (await rsp.json()) as T; return (await rsp.json()) as T;

View File

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

View File

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

View File

@ -22,7 +22,7 @@ const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>(
} }
}} }}
className={classNames( 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": "bg-neutral-800 cursor-not-allowed text-neutral-500":
!hasBg && props.disabled === true, !hasBg && props.disabled === true,

View File

@ -1,20 +1,80 @@
import { VmCostPlan } from "../api"; import useLogin from "../hooks/login";
export default function CostLabel({ cost }: { cost: VmCostPlan }) { interface Price {
function intervalName(n: string) { currency: string;
switch (n) { amount: number;
case "day": }
return "Day"; type Cost = Price & { interval_type?: string };
case "month":
return "Month"; export default function CostLabel({
case "year": cost,
return "Year"; }: {
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 ( return (
<> <div className={className}>
{cost.amount} {cost.currency}/{intervalName(cost.interval_type)} {converted && "~"}
</> {cost.currency !== "BTC"
? formatter.format(cost.amount)
: Math.floor(cost.amount * 1e8).toLocaleString()}
{cost.currency === "BTC" && " sats"}
{cost.interval_type && <>/{intervalName(cost.interval_type)}</>}
</div>
); );
} }

View File

@ -1,6 +1,8 @@
import classNames from "classnames"; import classNames from "classnames";
import { MouseEventHandler } from "react"; import { MouseEventHandler } from "react";
import Icons from "../icons.svg?no-inline";
type Props = { type Props = {
name: string; name: string;
size?: number; size?: number;
@ -10,7 +12,7 @@ type Props = {
export function Icon(props: Props) { export function Icon(props: Props) {
const size = props.size || 20; const size = props.size || 20;
const href = `/icons.svg#${props.name}`; const href = `${Icons}#${props.name}`;
return ( return (
<svg <svg

View File

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

View File

@ -1,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;
}

View File

@ -1,134 +1,195 @@
import "./markdown.css";
import { ReactNode, forwardRef, useMemo } from "react"; import { ReactNode, forwardRef, useMemo } from "react";
import { Token, Tokens, marked } from "marked"; import { Token, Tokens, marked } from "marked";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
interface MarkdownProps { 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; let ctr = 0;
function renderToken(t: Token): ReactNode { function renderToken(t: Token): ReactNode {
try { try {
switch (t.type) { switch (t.type) {
case "paragraph": { case "paragraph": {
return <div key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</div>; return (
} <p key={ctr++} className="py-2">
case "image": { {t.tokens ? t.tokens.map(renderToken) : t.raw}
return <img key={ctr++} src={t.href} />; </p>
} );
case "heading": { }
switch (t.depth) { case "image": {
case 1: return <img key={ctr++} src={t.href} />;
return <h1 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h1>; }
case 2: case "heading": {
return <h2 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h2>; switch (t.depth) {
case 3: case 1:
return <h3 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h3>; return (
case 4: <h1 key={ctr++} className="my-6 text-2xl">
return <h4 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h4>; {t.tokens ? t.tokens.map(renderToken) : t.raw}
case 5: </h1>
return <h5 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h5>; );
case 6: case 2:
return <h6 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h6>; return (
} <h2 key={ctr++} className="my-5 text-xl">
throw new Error("Invalid heading"); {t.tokens ? t.tokens.map(renderToken) : t.raw}
} </h2>
case "codespan": { );
return <code key={ctr++}>{t.raw}</code>; case 3:
} return (
case "code": { <h3 key={ctr++} className="my-4 text-lg">
return <pre key={ctr++}>{t.raw}</pre>; {t.tokens ? t.tokens.map(renderToken) : t.raw}
} </h3>
case "br": { );
return <br key={ctr++} />; case 4:
} return (
case "hr": { <h4 key={ctr++} className="my-3 text-md">
return <hr key={ctr++} />; {t.tokens ? t.tokens.map(renderToken) : t.raw}
} </h4>
case "strong": { );
return <b key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</b>; case 5:
} return (
case "blockquote": { <h5 key={ctr++} className="my-2">
return <blockquote key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</blockquote>; {t.tokens ? t.tokens.map(renderToken) : t.raw}
} </h5>
case "link": { );
return ( case 6:
<Link to={t.href} key={ctr++}> return (
{t.tokens ? t.tokens.map(renderToken) : t.raw} <h6 key={ctr++} className="my-2">
</Link> {t.tokens ? t.tokens.map(renderToken) : t.raw}
); </h6>
} );
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}`);
}
} }
} catch (e) { throw new Error("Invalid heading");
console.error(e); }
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(() => { const parsed = useMemo(() => {
return marked.lexer(props.content); return marked.lexer(props.content);
}, [props.content]); }, [props.content]);
return ( return (
<div className="markdown" ref={ref}> <div className="leading-8 text-pretty break-words" ref={ref}>
{parsed.filter(a => a.type !== "footnote" && a.type !== "footnotes").map(a => renderToken(a))} {parsed
</div> .filter((a) => a.type !== "footnote" && a.type !== "footnotes")
.map((a) => renderToken(a))}
</div>
); );
}); },
);
export default Markdown; export default Markdown;

View File

@ -51,7 +51,7 @@ export default function Modal(props: ModalProps) {
className={ className={
props.bodyClassName ?? props.bodyClassName ??
classNames( 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-[calc(100vh-100dvh)]": props.ready ?? true,
"max-xl:translate-y-[50vh]": !(props.ready ?? true), "max-xl:translate-y-[50vh]": !(props.ready ?? true),

View File

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

View File

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

View File

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

View File

@ -2,7 +2,13 @@ import { hexToBech32 } from "@snort/shared";
import { NostrLink } from "@snort/system"; import { NostrLink } from "@snort/system";
import { useUserProfile } from "@snort/system-react"; 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 profile = useUserProfile(link.id);
const name = profile?.display_name ?? profile?.name ?? ""; const name = profile?.display_name ?? profile?.name ?? "";
return ( return (
@ -11,9 +17,11 @@ export default function Profile({ link }: { link: NostrLink }) {
src={profile?.picture} src={profile?.picture}
className="w-12 h-12 rounded-full bg-neutral-800 object-cover object-center" className="w-12 h-12 rounded-full bg-neutral-800 object-cover object-center"
/> />
<div> {(withName ?? true) && (
{name.length > 0 ? name : hexToBech32("npub", link.id).slice(0, 12)} <div>
</div> {name.length > 0 ? name : hexToBech32("npub", link.id).slice(0, 12)}
</div>
)}
</div> </div>
); );
} }

View File

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

View File

@ -18,6 +18,7 @@ export default function VmActions({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex gap-2"> <div className="flex gap-2">
<AsyncButton <AsyncButton
title={state === "running" ? "Stop VM" : "Start VM"}
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
@ -40,15 +41,24 @@ export default function VmActions({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); 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>
</div> </div>
); );

View File

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

View File

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

View File

@ -41,7 +41,12 @@ export default function VpsInstanceRow({
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{isExpired && ( {isExpired && (
<> <>
<Link to="/vm/renew" className="text-red-500 text-sm" state={vm}> <Link
to="/vm/billing/renew"
className="text-red-500 text-sm"
state={vm}
onClick={(e) => e.stopPropagation()}
>
Expired Expired
</Link> </Link>
</> </>

View File

@ -11,7 +11,8 @@ export default function VpsPayment({
onPaid?: () => void; onPaid?: () => void;
}) { }) {
const login = useLogin(); const login = useLogin();
const ln = `lightning:${payment.invoice}`; const invoice = payment.data.lightning;
const ln = `lightning:${invoice}`;
async function checkPayment(api: LNVpsApi) { async function checkPayment(api: LNVpsApi) {
try { try {
@ -35,7 +36,7 @@ export default function VpsPayment({
} }
}, 2_000); }, 2_000);
return () => clearInterval(tx); return () => clearInterval(tx);
}, [login]); }, [login, onPaid]);
return ( return (
<div className="flex flex-col gap-4 rounded-xl p-3 bg-neutral-900 items-center"> <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" avatar="/logo.jpg"
className="cursor-pointer rounded-xl overflow-hidden" 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> </div>
); );
} }

View File

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

View File

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

View File

@ -1,25 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
<defs> <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 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"/> <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> </symbol>
<svg id="stop" viewBox="0 0 24 24" fill="none"> <symbol id="stop" viewBox="0 0 24 24" fill="none">
<path d="M24 0V24H0V0H24Z" fill="white" fill-opacity="0.01"/> <path d="M24 0V24H0V0H24Z" fill="white" fill-opacity="0.01"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="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"/> <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> </symbol>
<svg id="delete" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <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 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"/> <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> </symbol>
<svg id="refresh-1" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <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 d="M24 0V24H0V0H24Z" fill="white" fill-opacity="0.01"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0589 19.2443C12.8824 20.0009 15.764 19.0421 17.5934 16.9994C18.146 16.3822 19.0943 16.33 19.7115 16.8826C20.3286 17.4353 20.3808 18.3836 19.8282 19.0008C17.2737 21.8532 13.2404 23.2026 9.28249 22.1421C3.6811 20.6412 0.35698 14.8837 1.85787 9.2823C3.35876 3.68091 9.1163 0.356795 14.7177 1.85768C18.9224 2.98433 21.8407 6.50832 22.4032 10.5596C22.4653 11.0066 22.4987 11.4603 22.502 11.9179C22.5117 13.2319 21.0529 13.9572 20.01 13.2545L17.3364 11.4531C15.8701 10.4651 16.8533 8.17943 18.579 8.56459L18.6789 8.58688C17.7458 6.76269 16.0738 5.32688 13.9412 4.75546C9.94024 3.6834 5.82771 6.05777 4.75565 10.0588C3.68358 14.0598 6.05795 18.1723 10.0589 19.2443Z" fill="#F7F9FC"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M10.0589 19.2443C12.8824 20.0009 15.764 19.0421 17.5934 16.9994C18.146 16.3822 19.0943 16.33 19.7115 16.8826C20.3286 17.4353 20.3808 18.3836 19.8282 19.0008C17.2737 21.8532 13.2404 23.2026 9.28249 22.1421C3.6811 20.6412 0.35698 14.8837 1.85787 9.2823C3.35876 3.68091 9.1163 0.356795 14.7177 1.85768C18.9224 2.98433 21.8407 6.50832 22.4032 10.5596C22.4653 11.0066 22.4987 11.4603 22.502 11.9179C22.5117 13.2319 21.0529 13.9572 20.01 13.2545L17.3364 11.4531C15.8701 10.4651 16.8533 8.17943 18.579 8.56459L18.6789 8.58688C17.7458 6.76269 16.0738 5.32688 13.9412 4.75546C9.94024 3.6834 5.82771 6.05777 4.75565 10.0588C3.68358 14.0598 6.05795 18.1723 10.0589 19.2443Z" fill="#F7F9FC"/>
</svg> </symbol>
<svg id="pencil" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <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 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"/> <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> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -4,6 +4,7 @@
:root { :root {
font-family: "Source Code Pro", monospace; font-family: "Source Code Pro", monospace;
font-size: 15px;
@apply bg-black text-white; @apply bg-black text-white;
} }

View File

@ -11,6 +11,7 @@ export interface LoginSession {
publicKey: string; publicKey: string;
privateKey?: string; privateKey?: string;
bunker?: string; bunker?: string;
currency: string;
} }
class LoginStore extends ExternalStore<LoginSession | undefined> { class LoginStore extends ExternalStore<LoginSession | undefined> {
#session?: LoginSession; #session?: LoginSession;
@ -42,6 +43,7 @@ class LoginStore extends ExternalStore<LoginSession | undefined> {
this.#session = { this.#session = {
type: type ?? "nip7", type: type ?? "nip7",
publicKey: pubkey, publicKey: pubkey,
currency: "EUR",
}; };
this.#save(); this.#save();
} }
@ -52,6 +54,7 @@ class LoginStore extends ExternalStore<LoginSession | undefined> {
type: "nsec", type: "nsec",
publicKey: s.getPubKey(), publicKey: s.getPubKey(),
privateKey: key, privateKey: key,
currency: "EUR",
}; };
this.#save(); this.#save();
} }
@ -62,6 +65,7 @@ class LoginStore extends ExternalStore<LoginSession | undefined> {
publicKey: remotePubkey, publicKey: remotePubkey,
privateKey: localKey, privateKey: localKey,
bunker: url, bunker: url,
currency: "EUR",
}; };
this.#save(); this.#save();
} }
@ -99,6 +103,13 @@ class LoginStore extends ExternalStore<LoginSession | undefined> {
throw "Signer not setup!"; throw "Signer not setup!";
} }
updateSession(fx: (s: LoginSession) => void) {
if (this.#session) {
fx(this.#session);
this.#save();
}
}
#save() { #save() {
if (this.#session) { if (this.#session) {
window.localStorage.setItem("session", JSON.stringify(this.#session)); window.localStorage.setItem("session", JSON.stringify(this.#session));

View File

@ -11,6 +11,14 @@ import VmPage from "./pages/vm.tsx";
import AccountPage from "./pages/account.tsx"; import AccountPage from "./pages/account.tsx";
import SignUpPage from "./pages/sign-up.tsx"; import SignUpPage from "./pages/sign-up.tsx";
import { TosPage } from "./pages/terms.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({ const system = new NostrSystem({
automaticOutboxModel: false, automaticOutboxModel: false,
@ -39,18 +47,50 @@ const router = createBrowserRouter([
path: "/account", path: "/account",
element: <AccountPage />, element: <AccountPage />,
}, },
{
path: "/account/settings",
element: <AccountSettings />,
},
{
path: "/account/nostr-domain",
element: <AccountNostrDomainPage />,
},
{ {
path: "/order", path: "/order",
element: <OrderPage />, element: <OrderPage />,
}, },
{ {
path: "/vm/:action?", path: "/vm",
element: <VmPage />, element: <VmPage />,
}, },
{
path: "/vm/billing/:action?",
element: <VmBillingPage />,
},
{
path: "/vm/graphs",
element: <VmGraphsPage />,
},
{
path: "/vm/console",
element: <VmConsolePage />,
},
{ {
path: "/tos", path: "/tos",
element: <TosPage />, element: <TosPage />,
} },
{
path: "/status",
element: <StatusPage />,
},
{
path: "/news",
element: <NewsPage />,
},
{
path: "/news/:id",
element: <NewsPost />,
},
], ],
}, },
]); ]);

View File

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

View File

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

View File

@ -1,15 +1,15 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AccountDetail, LNVpsApi, VmInstance } from "../api"; import { LNVpsApi, VmInstance } from "../api";
import useLogin from "../hooks/login"; import useLogin from "../hooks/login";
import VpsInstanceRow from "../components/vps-instance"; import VpsInstanceRow from "../components/vps-instance";
import { hexToBech32 } from "@snort/shared"; import { hexToBech32 } from "@snort/shared";
import { Icon } from "../components/icon";
import { AsyncButton } from "../components/button"; import { AsyncButton } from "../components/button";
import { useNavigate } from "react-router-dom";
import { AccountNostrDomains } from "../components/account-domains";
export default function AccountPage() { export default function AccountPage() {
const login = useLogin(); const login = useLogin();
const [acc, setAcc] = useState<AccountDetail>(); const navigate = useNavigate();
const [editEmail, setEditEmail] = useState(false);
const [vms, setVms] = useState<Array<VmInstance>>([]); const [vms, setVms] = useState<Array<VmInstance>>([]);
async function loadVms(api: LNVpsApi) { async function loadVms(api: LNVpsApi) {
@ -20,7 +20,6 @@ export default function AccountPage() {
useEffect(() => { useEffect(() => {
if (login?.api) { if (login?.api) {
loadVms(login.api); loadVms(login.api);
login.api.getAccount().then(setAcc);
const t = setInterval(() => { const t = setInterval(() => {
loadVms(login.api); loadVms(login.api);
}, 5_000); }, 5_000);
@ -28,64 +27,51 @@ export default function AccountPage() {
} }
}, [login]); }, [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 npub = hexToBech32("npub", login?.publicKey);
const subjectLine = `[${npub}] Account Query`; const subjectLine = `[${npub}] Account Query`;
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
Your Public Key: Your Public Key:
<pre className="bg-neutral-900 rounded-md px-3 py-2 select-all text-sm">{npub}</pre> <pre className="bg-neutral-900 rounded-md px-3 py-2 select-all text-sm">
{notifications()} {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> <h3>My Resources</h3>
<div className="rounded-xl bg-red-400 text-black p-3"> <div className="rounded-xl bg-red-400 text-black p-3">
Something doesnt look right? <br /> Something doesnt look right? <br />
Please contact support on: {" "} Please contact support on:{" "}
<a href={`mailto:sales@lnvps.net?subject=${encodeURIComponent(subjectLine)}`} className="underline"> <a
href={`mailto:sales@lnvps.net?subject=${encodeURIComponent(subjectLine)}`}
className="underline"
>
sales@lnvps.net sales@lnvps.net
</a> </a>
<br /> <br />
<b>Please include your public key in all communications.</b> <b>Please include your public key in all communications.</b>
</div> </div>
{vms.length > 0 && <h3>VPS</h3>}
{vms.map((a) => ( {vms.map((a) => (
<VpsInstanceRow key={a.id} vm={a} onReload={() => { <VpsInstanceRow
if (login?.api) { key={a.id}
loadVms(login.api); vm={a}
} onReload={() => {
}} /> if (login?.api) {
loadVms(login.api);
}
}}
/>
))} ))}
<AccountNostrDomains />
</div> </div>
); );
} }

View File

@ -1,55 +1,181 @@
import { useState, useEffect } from "react"; import { useState, useEffect, ReactNode } from "react";
import { VmTemplate, LNVpsApi } from "../api"; import { DiskType, LNVpsApi, VmHostRegion, VmTemplateResponse } from "../api";
import Profile from "../components/profile";
import VpsCard from "../components/vps-card"; import VpsCard from "../components/vps-card";
import { ApiUrl, NostrProfile } from "../const"; import { ApiUrl, NostrProfile } from "../const";
import { Link } from "react-router-dom"; 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() { 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(() => { useEffect(() => {
const api = new LNVpsApi(ApiUrl, undefined); 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 ( return (
<> <>
<h1>VPS Offers</h1> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-2"> <LatestNews />
<div className="grid grid-cols-3 gap-2"> <div className="text-2xl">VPS Offers</div>
{offers.map((a) => ( <div>
<VpsCard spec={a} key={a.id} /> Virtual Private Server hosting with flexible plans, high uptime, and
))} dedicated support, tailored to your needs.
</div> </div>
<div className="flex gap-4 items-center">
<small> {Object.keys(regions).length > 1 && (
All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic <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> </small>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-4">
<a target="_blank" href={`https://snort.social/${NostrProfile.encode()}`}>
<Profile link={NostrProfile} />
</a>
<div className="text-center"> <div className="text-center">
<a target="_blank" href="http://speedtest.v0l.io">
Speedtest
</a>
{" | "}
<a href="/lnvps.asc">PGP</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="/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>
<div className="text-xs text-center text-neutral-400"> {import.meta.env.VITE_FOOTER_NOTE_1 && (
LNVPS is a trading name of Apex Strata Ltd, a company registered in Ireland. <div className="text-xs text-center text-neutral-400">
<br /> {import.meta.env.VITE_FOOTER_NOTE_1}
Comany Number: 702423, </div>
Address: Suite 10628, 26/27 Upper Pembroke Street, Dublin 2, D02 X361, Ireland )}
{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> </div>
</div> </div>
</> </>
); );
} }
function FilterSection({
header,
children,
}: {
header?: ReactNode;
children?: ReactNode;
}) {
return (
<div className="flex flex-col gap-2 bg-neutral-900 px-3 py-2 rounded-xl">
<div className="text-md text-neutral-400">{header}</div>
<div className="flex gap-2 items-center">{children}</div>
</div>
);
}

View File

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

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

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

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

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

View File

@ -8,6 +8,7 @@ import classNames from "classnames";
import VpsResources from "../components/vps-resources"; import VpsResources from "../components/vps-resources";
import OsImageName from "../components/os-image-name"; import OsImageName from "../components/os-image-name";
import SSHKeySelector from "../components/ssh-keys"; import SSHKeySelector from "../components/ssh-keys";
import { clearRefCode, getRefCode } from "../ref";
export default function OrderPage() { export default function OrderPage() {
const { state } = useLocation(); const { state } = useLocation();
@ -29,8 +30,24 @@ export default function OrderPage() {
setOrderError(""); setOrderError("");
try { try {
const newVm = await login.api.orderVm(template.id, useImage, useSshKey); const ref = getRefCode();
navigate("/vm/renew", { 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, state: newVm,
}); });
} catch (e) { } catch (e) {

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

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

View File

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

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

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

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

@ -0,0 +1,57 @@
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import useLogin from "../hooks/login";
import { VmInstance } from "../api";
import { WebglAddon } from "@xterm/addon-webgl";
import { AttachAddon } from "@xterm/addon-attach";
const fit = new FitAddon();
export function VmConsolePage() {
const { state } = useLocation() as { state?: VmInstance };
const login = useLogin();
const [term, setTerm] = useState<Terminal>();
const termRef = useRef<HTMLDivElement | null>(null);
async function openTerminal() {
if (!login?.api || !state) return;
const ws = await login.api.connect_terminal(state.id);
const te = new Terminal();
const webgl = new WebglAddon();
webgl.onContextLoss(() => {
webgl.dispose();
});
te.loadAddon(webgl);
te.loadAddon(fit);
const attach = new AttachAddon(ws);
attach.activate(te);
setTerm((t) => {
if (t) {
t.dispose();
}
return te;
});
}
useEffect(() => {
if (term && termRef.current) {
termRef.current.innerHTML = "";
term.open(termRef.current);
term.focus();
fit.fit();
}
}, [termRef, term]);
useEffect(() => {
openTerminal();
}, []);
return (
<div className="flex flex-col gap-4">
<div className="text-xl">VM #{state?.id} Terminal:</div>
{term && <div className="border p-2" ref={termRef}></div>}
</div>
);
}

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

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

View File

@ -1,160 +1,189 @@
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useLocation, useNavigate, useParams } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { VmInstance, VmPayment } from "../api"; import { VmInstance, VmIpAssignment } from "../api";
import VpsInstanceRow from "../components/vps-instance"; import VpsInstanceRow from "../components/vps-instance";
import useLogin from "../hooks/login"; import useLogin from "../hooks/login";
import { useCallback, useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import VpsPayment from "../components/vps-payment";
import CostLabel from "../components/cost";
import { AsyncButton } from "../components/button"; import { AsyncButton } from "../components/button";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { toEui64 } from "../utils";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import Modal from "../components/modal"; import Modal from "../components/modal";
import SSHKeySelector from "../components/ssh-keys"; import SSHKeySelector from "../components/ssh-keys";
const fit = new FitAddon();
export default function VmPage() { export default function VmPage() {
const { state } = useLocation() as { state?: VmInstance }; const location = useLocation() as { state?: VmInstance };
const { action } = useParams();
const login = useLogin(); const login = useLogin();
const navigate = useNavigate(); const navigate = useNavigate();
const [payment, setPayment] = useState<VmPayment>(); const [state, setState] = useState<VmInstance | undefined>(location?.state);
const [term] = useState<Terminal>();
const termRef = useRef<HTMLDivElement | null>(null);
const [editKey, setEditKey] = useState(false); 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 reloadVmState() {
async function () { if (!state) return;
if (!login?.api || !state) return; const newState = await login?.api.getVm(state.id);
const p = await login?.api.renewVm(state.id); setState(newState);
setPayment(p); }
},
[login?.api, state],
);
/*async function openTerminal() { function ipRow(a: VmIpAssignment, reverse: boolean) {
if (!login?.api || !state) return; return (
const ws = await login.api.connect_terminal(state.id); <div
const te = new Terminal(); key={a.id}
const webgl = new WebglAddon(); className="bg-neutral-900 px-2 py-3 rounded-lg flex gap-2 flex-col justify-center"
webgl.onContextLoss(() => { >
webgl.dispose(); <div>
}); <span className="select-none">IP: </span>
te.loadAddon(webgl); <span className="select-all">{a.ip.split("/")[0]}</span>
te.loadAddon(fit); </div>
te.onResize(({ cols, rows }) => { {a.forward_dns && (
ws.send(`${cols}:${rows}`); <div className="text-sm select-none">
}); DNS: <span className="select-all">{a.forward_dns}</span>
const attach = new AttachAddon(ws); </div>
te.loadAddon(attach); )}
setTerm(te); {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(() => { useEffect(() => {
if (term && termRef.current) { const t = setInterval(() => reloadVmState(), 5000);
term.open(termRef.current); return () => clearInterval(t);
term.focus(); }, []);
fit.fit();
}
}, [termRef, term, fit]);
useEffect(() => { function bestHost() {
switch (action) { if (!state) return;
case "renew": if (state.ip_assignments.length > 0) {
renew(); const ip = state.ip_assignments.at(0)!;
return ip.forward_dns ? ip.forward_dns : ip.ip.split("/")[0];
} }
}, [renew, action]); }
if (!state) { if (!state) {
return <h2>No VM selected</h2>; return <h2>No VM selected</h2>;
} }
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Link to={"/account"}>&lt; Back</Link>
<VpsInstanceRow vm={state} actions={true} /> <VpsInstanceRow vm={state} actions={true} />
{action === undefined && (
<> <div className="text-xl">Network:</div>
<div className="flex gap-4 items-center"> <div className="grid grid-cols-2 gap-4">{networkInfo()}</div>
<div className="text-xl">Network:</div> <div className="text-xl">SSH:</div>
{(state.ip_assignments?.length ?? 0) === 0 && ( <div className="grid grid-cols-2 gap-4">
<div className="text-sm text-red-500">No IP's assigned</div> <div className="bg-neutral-900 px-2 py-3 rounded-lg flex gap-2 items-center">
)} <div>Key:</div>
{state.ip_assignments?.map((a) => ( <div className="text-sm bg-neutral-900 px-3 py-1 rounded-lg">
<div {state.ssh_key?.name}
key={a.id}
className="text-sm bg-neutral-900 px-3 py-1 rounded-lg"
>
{a.ip.split("/")[0]}
</div>
))}
<div className="text-sm bg-neutral-900 px-3 py-1 rounded-lg">
{toEui64("2a13:2c0::", state.mac_address)}
</div>
</div> </div>
<div className="flex gap-4 items-center"> <Icon name="pencil" onClick={() => setEditKey(true)} />
<div className="text-xl">SSH Key:</div> </div>
<div className="text-sm bg-neutral-900 px-3 py-1 rounded-lg"> {!hasNoIps && (
{state.ssh_key?.name} <div className="bg-neutral-900 px-2 py-3 rounded-lg flex gap-2 items-center">
</div> <div>Login:</div>
<Icon name="pencil" onClick={() => setEditKey(true)} /> <pre className="select-all bg-neutral-800 px-3 py-1 rounded-full">
ssh {state.image.default_username}@{bestHost()}
</pre>
</div> </div>
<hr /> )}
<div className="text-xl">Renewal</div> </div>
<div className="flex justify-between items-center"> <hr />
<div>{new Date(state.expires).toDateString()}</div> <div className="flex gap-4">
{state.template?.cost_plan && ( {/*<AsyncButton onClick={() => navigate("/vm/console", { state })}>
<div> Console
<CostLabel cost={state.template?.cost_plan} /> </AsyncButton>*/}
</div> <AsyncButton onClick={() => navigate("/vm/billing", { state })}>
)} Billing
</div> </AsyncButton>
<AsyncButton onClick={() => navigate("/vm/renew", { state })}> <AsyncButton onClick={() => navigate("/vm/graphs", { state })}>
Extend Now Graphs
</AsyncButton> </AsyncButton>
{/* </div>
{!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);
}}
/>
)}
</>
)}
{editKey && ( {editKey && (
<Modal id="edit-ssh-key" onClose={() => setEditKey(false)}> <Modal id="edit-ssh-key" onClose={() => setEditKey(false)}>
<SSHKeySelector selectedKey={key} setSelectedKey={setKey} /> <SSHKeySelector selectedKey={key} setSelectedKey={setKey} />
<div className="flex flex-col gap-4 mt-8"> <div className="flex flex-col gap-4 mt-8">
<small>After selecting a new key, please restart the VM.</small> <small>After selecting a new key, please restart the VM.</small>
{error && <b className="text-red-500">{error}</b>}
<AsyncButton <AsyncButton
onClick={async () => { onClick={async () => {
setError(undefined);
if (!login?.api) return; if (!login?.api) return;
await login.api.patchVm(state.id, { try {
ssh_key_id: key, await login.api.patchVm(state.id, {
}); ssh_key_id: key,
const ns = await login.api.getVm(state?.id); });
navigate(".", { await reloadVmState();
state: ns, setEditKey(false);
replace: true, } catch (e) {
}); if (e instanceof Error) {
setEditKey(false); 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 Save

37
src/ref.ts Normal file
View File

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

11
src/status.json Normal file
View File

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

View File

@ -1,13 +1,16 @@
# Terms of Service # Terms of Service
**LNVPS** **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. 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 ## 1. Company Information
LNVPS is a trading name of Apex Strata Ltd, a company registered in Ireland. LNVPS is a trading name of Apex Strata Ltd, a company registered in Ireland.
- **Company Registration Number**: 702423 - **Company Registration Number**: 702423
- **Registered Office**: Suite 10628, 26/27 Upper Pembroke Street, Dublin 2, D02 X361, Ireland - **Registered Office**: Suite 10628, 26/27 Upper Pembroke Street, Dublin 2, D02 X361, Ireland
- **Email**: sales@lnvps.net - **Email**: sales@lnvps.net
@ -15,6 +18,7 @@ LNVPS is a trading name of Apex Strata Ltd, a company registered in Ireland.
--- ---
## 2. Definitions ## 2. Definitions
- **"You" or "Customer"**: The individual or entity subscribing to or using the Services. - **"You" or "Customer"**: The individual or entity subscribing to or using the Services.
- **"We", "Us", or "LNVPS"**: Apex Strata Ltd, operating as LNVPS. - **"We", "Us", or "LNVPS"**: Apex Strata Ltd, operating as LNVPS.
- **"Services"**: VPS hosting, support, and any additional features provided by LNVPS. - **"Services"**: VPS hosting, support, and any additional features provided by LNVPS.
@ -22,11 +26,13 @@ LNVPS is a trading name of Apex Strata Ltd, a company registered in Ireland.
--- ---
## 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. 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. 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. - **Service Availability**: We strive for 99.9% uptime but do not guarantee uninterrupted service. Downtime may occur due to maintenance, upgrades, or unforeseen events.
@ -35,6 +41,7 @@ LNVPS provides VPS hosting services, including server resources, bandwidth, and
--- ---
## 5. Account Responsibilities ## 5. Account Responsibilities
- **Account Security**: You are responsible for maintaining the confidentiality of your account credentials and for all activities under your account. - **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. - **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. - **Notification**: You must notify us immediately of any unauthorized use of your account.
@ -42,7 +49,9 @@ LNVPS provides VPS hosting services, including server resources, bandwidth, and
--- ---
## 6. Acceptable Use Policy ## 6. Acceptable Use Policy
You agree not to use the Services to: 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. - 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. - Engage in spamming, phishing, or other abusive activities.
- Overload or disrupt our servers, networks, or other customers services (e.g., DDoS attacks). - Overload or disrupt our servers, networks, or other customers services (e.g., DDoS attacks).
@ -53,6 +62,7 @@ We reserve the right to suspend or terminate your Services without notice if we
--- ---
## 7. Payment and Billing ## 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. - **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). - **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. - **Late Payment**: Overdue accounts may be suspended until payment is received.
@ -61,6 +71,7 @@ We reserve the right to suspend or terminate your Services without notice if we
--- ---
## 8. Termination ## 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 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. - **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. - **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.
@ -68,6 +79,7 @@ We reserve the right to suspend or terminate your Services without notice if we
--- ---
## 9. Data and Privacy ## 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. - **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. - **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). - **GDPR Compliance**: We process personal data in accordance with our [Privacy Policy](#), which complies with the General Data Protection Regulation (GDPR).
@ -75,7 +87,9 @@ We reserve the right to suspend or terminate your Services without notice if we
--- ---
## 10. Limitation of Liability ## 10. Limitation of Liability
To the fullest extent permitted by Irish law: 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. - 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 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). - We are not responsible for issues beyond our reasonable control, such as force majeure events (e.g., natural disasters, cyber-attacks).
@ -83,24 +97,29 @@ To the fullest extent permitted by Irish law:
--- ---
## 11. Intellectual Property ## 11. Intellectual Property
- **Our IP**: The LNVPS website, branding, and software remain our property or that of our licensors. - **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. - **Your IP**: You grant us a limited license to use your content solely to provide the Services.
--- ---
## 12. Governing Law and Dispute Resolution ## 12. Governing Law and Dispute Resolution
- These Terms are governed by the laws of Ireland. - 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. - 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. 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 ## 14. Contact Us
For questions or support: For questions or support:
- **Email**: sales@lnvps.net - **Email**: sales@lnvps.net
- **Address**: Suite 10628, 26/27 Upper Pembroke Street, Dublin 2, D02 X361, Ireland - **Address**: Suite 10628, 26/27 Upper Pembroke Street, Dublin 2, D02 X361, Ireland

View File

@ -51,3 +51,29 @@ export function toEui64(prefix: string, mac: string) {
base16.encode(macExtended.subarray(6, 8)) base16.encode(macExtended.subarray(6, 8))
).toLowerCase(); ).toLowerCase();
} }
export function timeValue(n: number): string {
if (!Number.isFinite(n) || n < 0) {
return "Invalid input";
}
if (n >= 86400) {
const days = Math.floor(n / 86400);
return days.toLocaleString() + " day" + (days !== 1 ? "s" : "");
}
if (n >= 3600) {
const hours = Math.floor(n / 3600);
const minutes = Math.floor((n % 3600) / 60);
return (
hours +
" hr" +
(hours !== 1 ? "s" : "") +
(minutes > 0 ? " " + minutes + " min" + (minutes !== 1 ? "s" : "") : "")
);
}
if (n >= 60) {
const minutes = Math.floor(n / 60);
return minutes + " min" + (minutes !== 1 ? "s" : "");
}
return n + " sec" + (n !== 1 ? "s" : "");
}

View File

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

857
yarn.lock

File diff suppressed because it is too large Load Diff