Compare commits
10 Commits
7bdea28bc9
...
26d36adbeb
Author | SHA1 | Date | |
---|---|---|---|
26d36adbeb
|
|||
c1312d97f1
|
|||
57cc619b8c
|
|||
7cba506d6b
|
|||
8e3e4c0364
|
|||
1aab7c9372
|
|||
0b93b0d4f9
|
|||
c6e4a9e3c9
|
|||
7ba2659fbf
|
|||
b52735a0a4
|
@ -1 +1,2 @@
|
||||
#VITE_API_URL="http://localhost:8000"
|
||||
VITE_API_URL="http://localhost:8000"
|
||||
VITE_REVOLUT_MODE="sandbox"
|
@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@revolut/checkout": "^1.1.20",
|
||||
"@scure/base": "^1.2.1",
|
||||
"@snort/shared": "^1.0.17",
|
||||
"@snort/system": "^1.6.1",
|
||||
@ -19,11 +20,13 @@
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"classnames": "^2.5.1",
|
||||
"iso-3166-1": "^2.1.1",
|
||||
"marked": "^15.0.7",
|
||||
"qr-code-styling": "^1.8.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.0.1"
|
||||
"react-router-dom": "^7.0.1",
|
||||
"recharts": "^2.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
|
140
src/api.ts
140
src/api.ts
@ -9,17 +9,29 @@ export type ApiResponse<T> = ApiResponseBase & {
|
||||
data: T;
|
||||
};
|
||||
|
||||
export enum DiskType {
|
||||
SSD = "ssd",
|
||||
HDD = "hdd",
|
||||
}
|
||||
|
||||
export enum DiskInterface {
|
||||
SATA = "sata",
|
||||
SCSI = "scsi",
|
||||
PCIe = "pcid",
|
||||
}
|
||||
|
||||
export interface AccountDetail {
|
||||
email?: string;
|
||||
contact_nip17: boolean;
|
||||
contact_email: boolean;
|
||||
country_code: string;
|
||||
}
|
||||
|
||||
export interface VmCostPlan {
|
||||
id: number;
|
||||
name: string;
|
||||
amount: number;
|
||||
currency: "EUR" | "BTC";
|
||||
currency: string;
|
||||
interval_amount: number;
|
||||
interval_type: string;
|
||||
}
|
||||
@ -29,16 +41,54 @@ export interface VmHostRegion {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface VmCustomTemplateParams {
|
||||
id: number;
|
||||
name: string;
|
||||
region: VmHostRegion;
|
||||
max_cpu: number;
|
||||
min_cpu: number;
|
||||
min_memory: number;
|
||||
max_memory: number;
|
||||
min_disk: number;
|
||||
max_disk: number;
|
||||
disks: Array<VmCustomTemplateDiskParams>;
|
||||
}
|
||||
|
||||
export interface VmCustomTemplateDiskParams {
|
||||
disk_type: DiskType;
|
||||
disk_interface: DiskInterface;
|
||||
}
|
||||
|
||||
export interface VmCustomTemplateRequest {
|
||||
pricing_id: number;
|
||||
cpu: number;
|
||||
memory: number;
|
||||
disk: number;
|
||||
disk_type: DiskType;
|
||||
disk_interface: DiskInterface;
|
||||
}
|
||||
|
||||
export interface VmCustomPrice {
|
||||
currency: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface VmTemplateResponse {
|
||||
templates: Array<VmTemplate>;
|
||||
custom_template?: Array<VmCustomTemplateParams>;
|
||||
}
|
||||
|
||||
export interface VmTemplate {
|
||||
id: number;
|
||||
pricing_id?: number;
|
||||
name: string;
|
||||
created: Date;
|
||||
expires?: Date;
|
||||
cpu: number;
|
||||
memory: number;
|
||||
disk_size: number;
|
||||
disk_type: string;
|
||||
disk_interface: string;
|
||||
disk_type: DiskType;
|
||||
disk_interface: DiskInterface;
|
||||
cost_plan: VmCostPlan;
|
||||
region: VmHostRegion;
|
||||
}
|
||||
@ -89,11 +139,17 @@ export interface UserSshKey {
|
||||
|
||||
export interface VmPayment {
|
||||
id: string;
|
||||
invoice: string;
|
||||
created: string;
|
||||
expires: string;
|
||||
amount: number;
|
||||
tax: number;
|
||||
is_paid: boolean;
|
||||
data: {
|
||||
lightning?: string;
|
||||
revolut?: {
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface PatchVm {
|
||||
@ -101,11 +157,28 @@ export interface PatchVm {
|
||||
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 class LNVpsApi {
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly publisher: EventPublisher | undefined,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
async getAccount() {
|
||||
const { data } = await this.#handleResponse<ApiResponse<AccountDetail>>(
|
||||
@ -135,6 +208,13 @@ export class LNVpsApi {
|
||||
return data;
|
||||
}
|
||||
|
||||
async getVmTimeSeries(id: number) {
|
||||
const { data } = await this.#handleResponse<
|
||||
ApiResponse<Array<TimeSeriesData>>
|
||||
>(await this.#req(`/api/v1/vm/${id}/time-series`, "GET"));
|
||||
return data;
|
||||
}
|
||||
|
||||
async patchVm(id: number, req: PatchVm) {
|
||||
const { data } = await this.#handleResponse<ApiResponse<void>>(
|
||||
await this.#req(`/api/v1/vm/${id}`, "PATCH", req),
|
||||
@ -157,9 +237,9 @@ export class LNVpsApi {
|
||||
}
|
||||
|
||||
async listOffers() {
|
||||
const { data } = await this.#handleResponse<ApiResponse<Array<VmTemplate>>>(
|
||||
await this.#req("/api/v1/vm/templates", "GET"),
|
||||
);
|
||||
const { data } = await this.#handleResponse<
|
||||
ApiResponse<VmTemplateResponse>
|
||||
>(await this.#req("/api/v1/vm/templates", "GET"));
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -187,21 +267,50 @@ export class LNVpsApi {
|
||||
return data;
|
||||
}
|
||||
|
||||
async orderVm(template_id: number, image_id: number, ssh_key_id: number, ref_code?: string) {
|
||||
async orderVm(
|
||||
template_id: number,
|
||||
image_id: number,
|
||||
ssh_key_id: number,
|
||||
ref_code?: string,
|
||||
) {
|
||||
const { data } = await this.#handleResponse<ApiResponse<VmInstance>>(
|
||||
await this.#req("/api/v1/vm", "POST", {
|
||||
template_id,
|
||||
image_id,
|
||||
ssh_key_id,
|
||||
ref_code
|
||||
ref_code,
|
||||
}),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async renewVm(vm_id: number) {
|
||||
async customPrice(req: VmCustomTemplateRequest) {
|
||||
const { data } = await this.#handleResponse<ApiResponse<VmCustomPrice>>(
|
||||
await this.#req("/api/v1/vm/custom-template/price", "POST", req),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async orderCustom(
|
||||
req: VmCustomTemplateRequest,
|
||||
image_id: number,
|
||||
ssh_key_id: number,
|
||||
ref_code?: string,
|
||||
) {
|
||||
const { data } = await this.#handleResponse<ApiResponse<VmInstance>>(
|
||||
await this.#req("/api/v1/vm/custom-template", "POST", {
|
||||
...req,
|
||||
image_id,
|
||||
ssh_key_id,
|
||||
ref_code,
|
||||
}),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async renewVm(vm_id: number, method: string) {
|
||||
const { data } = await this.#handleResponse<ApiResponse<VmPayment>>(
|
||||
await this.#req(`/api/v1/vm/${vm_id}/renew`, "GET"),
|
||||
await this.#req(`/api/v1/vm/${vm_id}/renew?method=${method}`, "GET"),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@ -213,6 +322,13 @@ export class LNVpsApi {
|
||||
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/console/${id}`;
|
||||
const auth = await this.#auth_event(u, "GET");
|
||||
|
@ -22,7 +22,7 @@ const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>(
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
"py-1 px-2 rounded-xl font-medium relative",
|
||||
"py-2 px-3 rounded-xl font-medium relative",
|
||||
{
|
||||
"bg-neutral-800 cursor-not-allowed text-neutral-500":
|
||||
!hasBg && props.disabled === true,
|
||||
|
@ -1,6 +1,16 @@
|
||||
import { VmCostPlan } from "../api";
|
||||
interface Price {
|
||||
currency: string;
|
||||
amount: number;
|
||||
}
|
||||
type Cost = Price & { interval_type?: string; other_price?: Array<Price> };
|
||||
|
||||
export default function CostLabel({ cost }: { cost: VmCostPlan }) {
|
||||
export default function CostLabel({
|
||||
cost,
|
||||
converted,
|
||||
}: {
|
||||
cost: Cost;
|
||||
converted?: boolean;
|
||||
}) {
|
||||
function intervalName(n: string) {
|
||||
switch (n) {
|
||||
case "day":
|
||||
@ -13,8 +23,19 @@ export default function CostLabel({ cost }: { cost: VmCostPlan }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{cost.amount} {cost.currency}/{intervalName(cost.interval_type)}
|
||||
</>
|
||||
<div>
|
||||
{converted && "~"}
|
||||
{cost.currency !== "BTC"
|
||||
? cost.amount.toFixed(2)
|
||||
: Math.floor(cost.amount * 1e8).toLocaleString()}{" "}
|
||||
{cost.currency === "BTC" ? "sats" : cost.currency}
|
||||
{cost.interval_type && <>/{intervalName(cost.interval_type)}</>}
|
||||
{cost.other_price &&
|
||||
cost.other_price.map((a) => (
|
||||
<div key={a.currency} className="text-xs">
|
||||
<CostLabel cost={a} converted={true} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
24
src/components/latest-news.tsx
Normal file
24
src/components/latest-news.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { EventKind, RequestBuilder } from "@snort/system";
|
||||
import { NostrProfile } from "../const";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { NewLink } from "./news-link";
|
||||
|
||||
export function LatestNews() {
|
||||
const req = new RequestBuilder("latest-news");
|
||||
req
|
||||
.withFilter()
|
||||
.kinds([EventKind.LongFormTextNote])
|
||||
.authors([NostrProfile.id])
|
||||
.limit(1);
|
||||
|
||||
const posts = useRequestBuilder(req);
|
||||
|
||||
if (posts.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-xl">Latest News</div>
|
||||
<NewLink ev={posts[0]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
.markdown a {
|
||||
@apply underline;
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
margin: 0;
|
||||
padding-left: 12px;
|
||||
@apply border-l-neutral-800 border-2 text-neutral-400;
|
||||
}
|
||||
|
||||
.markdown hr {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
margin: 20px;
|
||||
@apply bg-neutral-800;
|
||||
}
|
||||
|
||||
.markdown img:not(.custom-emoji),
|
||||
.markdown video,
|
||||
.markdown iframe,
|
||||
.markdown audio {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.markdown iframe,
|
||||
.markdown video {
|
||||
width: -webkit-fill-available;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.markdown h1,
|
||||
.markdown h2,
|
||||
.markdown h3,
|
||||
.markdown h4,
|
||||
.markdown h5,
|
||||
.markdown h6 {
|
||||
margin: 0.5em 0;
|
||||
}
|
@ -1,5 +1,3 @@
|
||||
import "./markdown.css";
|
||||
|
||||
import { ReactNode, forwardRef, useMemo } from "react";
|
||||
import { Token, Tokens, marked } from "marked";
|
||||
import { Link } from "react-router-dom";
|
||||
@ -16,9 +14,9 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
|
||||
switch (t.type) {
|
||||
case "paragraph": {
|
||||
return (
|
||||
<div key={ctr++}>
|
||||
<p key={ctr++} className="py-2">
|
||||
{t.tokens ? t.tokens.map(renderToken) : t.raw}
|
||||
</div>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
case "image": {
|
||||
@ -28,37 +26,37 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
|
||||
switch (t.depth) {
|
||||
case 1:
|
||||
return (
|
||||
<h1 key={ctr++}>
|
||||
<h1 key={ctr++} className="my-6 text-2xl">
|
||||
{t.tokens ? t.tokens.map(renderToken) : t.raw}
|
||||
</h1>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<h2 key={ctr++}>
|
||||
<h2 key={ctr++} className="my-5 text-xl">
|
||||
{t.tokens ? t.tokens.map(renderToken) : t.raw}
|
||||
</h2>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<h3 key={ctr++}>
|
||||
<h3 key={ctr++} className="my-4 text-lg">
|
||||
{t.tokens ? t.tokens.map(renderToken) : t.raw}
|
||||
</h3>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<h4 key={ctr++}>
|
||||
<h4 key={ctr++} className="my-3 text-md">
|
||||
{t.tokens ? t.tokens.map(renderToken) : t.raw}
|
||||
</h4>
|
||||
);
|
||||
case 5:
|
||||
return (
|
||||
<h5 key={ctr++}>
|
||||
<h5 key={ctr++} className="my-2">
|
||||
{t.tokens ? t.tokens.map(renderToken) : t.raw}
|
||||
</h5>
|
||||
);
|
||||
case 6:
|
||||
return (
|
||||
<h6 key={ctr++}>
|
||||
<h6 key={ctr++} className="my-2">
|
||||
{t.tokens ? t.tokens.map(renderToken) : t.raw}
|
||||
</h6>
|
||||
);
|
||||
@ -66,7 +64,11 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
|
||||
throw new Error("Invalid heading");
|
||||
}
|
||||
case "codespan": {
|
||||
return <code key={ctr++}>{t.raw}</code>;
|
||||
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>;
|
||||
@ -84,23 +86,34 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
|
||||
}
|
||||
case "blockquote": {
|
||||
return (
|
||||
<blockquote key={ctr++}>
|
||||
<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++}>
|
||||
<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++}>{t.items.map(renderToken)}</ol>;
|
||||
return (
|
||||
<ol key={ctr++} className="list-decimal list-outside">
|
||||
{t.items.map(renderToken)}
|
||||
</ol>
|
||||
);
|
||||
} else {
|
||||
return <ul key={ctr++}>{t.items.map(renderToken)}</ul>;
|
||||
return (
|
||||
<ul key={ctr++} className="list-disc list-outside">
|
||||
{t.items.map(renderToken)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
case "list_item": {
|
||||
@ -170,7 +183,7 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
|
||||
return marked.lexer(props.content);
|
||||
}, [props.content]);
|
||||
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))}
|
||||
|
26
src/components/news-link.tsx
Normal file
26
src/components/news-link.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { NostrEvent, NostrLink } from "@snort/system";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function NewLink({ ev }: { ev: NostrEvent }) {
|
||||
const link = NostrLink.fromEvent(ev);
|
||||
const title = ev.tags.find((a) => a[0] == "title")?.[1];
|
||||
const posted = Number(
|
||||
ev.tags.find((a) => a[0] == "published_at")?.[1] ?? ev.created_at,
|
||||
);
|
||||
const slug = title
|
||||
?.toLocaleLowerCase()
|
||||
.replace(/[:/]/g, "")
|
||||
.trimStart()
|
||||
.trimEnd()
|
||||
.replace(/ /g, "-");
|
||||
return (
|
||||
<Link to={`/news/${slug}`} state={ev} key={link.tagKey}>
|
||||
<div className="flex flex-col rounded-xl bg-neutral-900 px-3 py-4">
|
||||
<div className="text-xl flex items-center justify-between">
|
||||
<div>{title}</div>
|
||||
<div>{new Date(posted * 1000).toDateString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
@ -18,16 +18,14 @@ export default function VpsPayButton({ spec }: { spec: VmTemplate }) {
|
||||
const navigte = useNavigate();
|
||||
|
||||
if (!login) {
|
||||
return <AsyncButton
|
||||
className={`${classNames} bg-red-900`}
|
||||
onClick={() =>
|
||||
navigte("/login", {
|
||||
state: spec,
|
||||
})
|
||||
}
|
||||
>
|
||||
Login To Order
|
||||
</AsyncButton>
|
||||
return (
|
||||
<AsyncButton
|
||||
className={`${classNames} bg-red-900`}
|
||||
onClick={() => navigte("/login")}
|
||||
>
|
||||
Login To Order
|
||||
</AsyncButton>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AsyncButton
|
||||
|
65
src/components/revolut.tsx
Normal file
65
src/components/revolut.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
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 = "";
|
||||
revolutPay.mount(ref, {
|
||||
currency: amount.currency,
|
||||
totalAmount: amount.amount,
|
||||
createOrder: async () => {
|
||||
const id = await loadOrder();
|
||||
return {
|
||||
publicId: id,
|
||||
};
|
||||
},
|
||||
buttonStyle: {
|
||||
cashback: false,
|
||||
},
|
||||
});
|
||||
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>;
|
||||
}
|
122
src/components/vps-custom.tsx
Normal file
122
src/components/vps-custom.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
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";
|
||||
|
||||
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 [ram, setRam] = useState(Math.floor((params.min_memory ?? GiB) / GiB));
|
||||
const [disk, setDisk] = useState(Math.floor((params.min_disk ?? GiB) / GiB));
|
||||
const [diskType] = useState(params.disks.at(0));
|
||||
|
||||
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>
|
||||
<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(params.min_disk / GiB)}
|
||||
max={Math.floor(params.max_disk / GiB)}
|
||||
step={1}
|
||||
className="grow"
|
||||
/>
|
||||
</div>
|
||||
{price && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xl flex-1">
|
||||
<CostLabel cost={cost_plan} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<VpsPayButton
|
||||
spec={{
|
||||
id: 0,
|
||||
pricing_id: params.id,
|
||||
cpu,
|
||||
name: "Custom",
|
||||
memory: ram * GiB,
|
||||
disk_size: disk * GiB,
|
||||
disk_type: diskType?.disk_type ?? DiskType.SSD,
|
||||
disk_interface: diskType?.disk_interface ?? DiskInterface.PCIe,
|
||||
created: new Date(),
|
||||
region: params.region,
|
||||
cost_plan,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -41,7 +41,12 @@ export default function VpsInstanceRow({
|
||||
<div className="flex gap-2 items-center">
|
||||
{isExpired && (
|
||||
<>
|
||||
<Link to="/vm/renew" className="text-red-500 text-sm" state={vm}>
|
||||
<Link
|
||||
to="/vm/billing/renew"
|
||||
className="text-red-500 text-sm"
|
||||
state={vm}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Expired
|
||||
</Link>
|
||||
</>
|
||||
|
@ -11,7 +11,8 @@ export default function VpsPayment({
|
||||
onPaid?: () => void;
|
||||
}) {
|
||||
const login = useLogin();
|
||||
const ln = `lightning:${payment.invoice}`;
|
||||
const invoice = payment.data.lightning;
|
||||
const ln = `lightning:${invoice}`;
|
||||
|
||||
async function checkPayment(api: LNVpsApi) {
|
||||
try {
|
||||
@ -47,8 +48,15 @@ export default function VpsPayment({
|
||||
avatar="/logo.jpg"
|
||||
className="cursor-pointer rounded-xl overflow-hidden"
|
||||
/>
|
||||
{(payment.amount / 1000).toLocaleString()} sats
|
||||
<div className="monospace select-all break-all text-center text-sm">{payment.invoice}</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
22
src/main.tsx
22
src/main.tsx
@ -13,6 +13,10 @@ import SignUpPage from "./pages/sign-up.tsx";
|
||||
import { TosPage } from "./pages/terms.tsx";
|
||||
import { StatusPage } from "./pages/status.tsx";
|
||||
import { AccountSettings } from "./pages/account-settings.tsx";
|
||||
import { VmBillingPage } from "./pages/vm-billing.tsx";
|
||||
import { VmGraphsPage } from "./pages/vm-graphs.tsx";
|
||||
import { NewsPage } from "./pages/news.tsx";
|
||||
import { NewsPost } from "./pages/news-post.tsx";
|
||||
|
||||
const system = new NostrSystem({
|
||||
automaticOutboxModel: false,
|
||||
@ -50,9 +54,17 @@ const router = createBrowserRouter([
|
||||
element: <OrderPage />,
|
||||
},
|
||||
{
|
||||
path: "/vm/:action?",
|
||||
path: "/vm",
|
||||
element: <VmPage />,
|
||||
},
|
||||
{
|
||||
path: "/vm/billing/:action?",
|
||||
element: <VmBillingPage />,
|
||||
},
|
||||
{
|
||||
path: "/vm/graphs",
|
||||
element: <VmGraphsPage />,
|
||||
},
|
||||
{
|
||||
path: "/tos",
|
||||
element: <TosPage />,
|
||||
@ -61,6 +73,14 @@ const router = createBrowserRouter([
|
||||
path: "/status",
|
||||
element: <StatusPage />,
|
||||
},
|
||||
{
|
||||
path: "/news",
|
||||
element: <NewsPage />,
|
||||
},
|
||||
{
|
||||
path: "/news/:id",
|
||||
element: <NewsPost />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
@ -3,6 +3,7 @@ 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();
|
||||
@ -13,63 +14,73 @@ export function AccountSettings() {
|
||||
login?.api.getAccount().then(setAcc);
|
||||
}, [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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (!acc) return;
|
||||
return <div className="flex flex-col gap-4">
|
||||
<h3>
|
||||
Account Settings
|
||||
</h3>
|
||||
|
||||
return <>{notifications()}</>;
|
||||
<div className="flex gap-2 items-center">
|
||||
<h4>Country</h4>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { VmTemplate, LNVpsApi } from "../api";
|
||||
import { LNVpsApi, VmTemplateResponse } from "../api";
|
||||
import VpsCard from "../components/vps-card";
|
||||
import { ApiUrl, NostrProfile } from "../const";
|
||||
import { Link } from "react-router-dom";
|
||||
import { VpsCustomOrder } from "../components/vps-custom";
|
||||
import { LatestNews } from "../components/latest-news";
|
||||
|
||||
export default function HomePage() {
|
||||
const [offers, setOffers] = useState<Array<VmTemplate>>();
|
||||
const [offers, setOffers] = useState<VmTemplateResponse>();
|
||||
|
||||
useEffect(() => {
|
||||
const api = new LNVpsApi(ApiUrl, undefined);
|
||||
@ -15,21 +17,25 @@ export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<LatestNews />
|
||||
<div className="text-2xl">VPS Offers</div>
|
||||
<div>
|
||||
Virtual Private Server hosting with flexible plans, high uptime, and
|
||||
dedicated support, tailored to your needs.
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{offers?.map((a) => (
|
||||
<VpsCard spec={a} key={a.id} />
|
||||
))}
|
||||
{offers !== undefined && offers.length === 0 &&
|
||||
<div className="text-red-500 bold text-xl uppercase">No offers available</div>}
|
||||
{offers?.templates.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>
|
||||
|
||||
<small className="text-neutral-400">
|
||||
All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic
|
||||
{offers?.custom_template && (
|
||||
<VpsCustomOrder templates={offers.custom_template} />
|
||||
)}
|
||||
<small className="text-neutral-400 text-center">
|
||||
All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic, all prices are excluding taxes.
|
||||
</small>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
@ -39,6 +45,8 @@ export default function HomePage() {
|
||||
{" | "}
|
||||
<Link to="/tos">Terms</Link>
|
||||
{" | "}
|
||||
<Link to="/news">News</Link>
|
||||
{" | "}
|
||||
<a
|
||||
href={`https://snort.social/${NostrProfile.encode()}`}
|
||||
target="_blank"
|
||||
|
@ -2,7 +2,6 @@ import { Link, Outlet } from "react-router-dom";
|
||||
import LoginButton from "../components/login-button";
|
||||
import { saveRefCode } from "../ref";
|
||||
|
||||
|
||||
export default function Layout() {
|
||||
saveRefCode();
|
||||
return (
|
||||
|
25
src/pages/news-post.tsx
Normal file
25
src/pages/news-post.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import Markdown from "../components/markdown";
|
||||
import Profile from "../components/profile";
|
||||
|
||||
export function NewsPost() {
|
||||
const { state } = useLocation() as { state?: TaggedNostrEvent };
|
||||
|
||||
if (!state) return;
|
||||
const title = state.tags.find((a) => a[0] == "title")?.[1];
|
||||
const posted = Number(
|
||||
state.tags.find((a) => a[0] == "published_at")?.[1] ?? state.created_at,
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<div className="text-2xl">{title}</div>
|
||||
<div className="flex items-center justify-between py-8">
|
||||
<Profile link={NostrLink.profile(state.pubkey, state.relays)} />
|
||||
<div>{new Date(posted * 1000).toLocaleString()}</div>
|
||||
</div>
|
||||
|
||||
<Markdown content={state.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
35
src/pages/news.tsx
Normal file
35
src/pages/news.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { EventKind, RequestBuilder } from "@snort/system";
|
||||
import { NostrProfile } from "../const";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { NewLink } from "../components/news-link";
|
||||
|
||||
export function NewsPage() {
|
||||
const req = new RequestBuilder("news");
|
||||
req
|
||||
.withFilter()
|
||||
.kinds([EventKind.LongFormTextNote])
|
||||
.authors([NostrProfile.id])
|
||||
.limit(10);
|
||||
|
||||
const posts = useRequestBuilder(req);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="text-2xl">News</div>
|
||||
{posts
|
||||
.sort((a, b) => {
|
||||
const a_posted = Number(
|
||||
a.tags.find((a) => a[0] == "published_at")?.[1] ?? a.created_at,
|
||||
);
|
||||
const b_posted = Number(
|
||||
b.tags.find((z) => z[0] == "published_at")?.[1] ?? b.created_at,
|
||||
);
|
||||
return b_posted - a_posted;
|
||||
})
|
||||
.map((a) => (
|
||||
<NewLink ev={a} />
|
||||
))}
|
||||
{posts.length === 0 && <div>No posts yet..</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -31,9 +31,23 @@ export default function OrderPage() {
|
||||
setOrderError("");
|
||||
try {
|
||||
const ref = getRefCode();
|
||||
const newVm = await login.api.orderVm(template.id, useImage, useSshKey, ref?.code);
|
||||
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/renew", {
|
||||
navigate("/vm/billing/renew", {
|
||||
state: newVm,
|
||||
});
|
||||
} catch (e) {
|
||||
|
164
src/pages/vm-billing.tsx
Normal file
164
src/pages/vm-billing.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
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 from "../components/cost";
|
||||
import { RevolutPayWidget } from "../components/revolut";
|
||||
|
||||
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 [state, setState] = useState<VmInstance | undefined>(location?.state);
|
||||
|
||||
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";
|
||||
|
||||
switch (v.name) {
|
||||
case "lightning": {
|
||||
return (
|
||||
<div
|
||||
key={v.name}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
setMethod(v);
|
||||
renew(v.name);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{v.name.toUpperCase()} ({v.currencies.join(",")})
|
||||
</div>
|
||||
<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}>
|
||||
<div>
|
||||
{v.name.toUpperCase()} ({v.currencies.join(",")})
|
||||
</div>
|
||||
{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();
|
||||
}
|
||||
}, [login, state, params, renew]);
|
||||
|
||||
if (!state) return;
|
||||
const expireDate = new Date(state.expires);
|
||||
const days =
|
||||
(expireDate.getTime() - new Date().getTime()) / 1000 / 24 / 60 / 60;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Link to={"/vm"} state={state}>
|
||||
< Back
|
||||
</Link>
|
||||
<div className="text-xl bg-neutral-900 rounded-xl px-3 py-4 flex justify-between items-center">
|
||||
<div>Renewal for #{state.id}</div>
|
||||
<div>
|
||||
<CostLabel cost={state.template.cost_plan} />
|
||||
<span className="text-sm">ex. tax</span>
|
||||
</div>
|
||||
</div>
|
||||
{days > 0 && (
|
||||
<div>
|
||||
Expires: {expireDate.toDateString()} ({Math.floor(days)} days)
|
||||
</div>
|
||||
)}
|
||||
{days < 0 && !methods && (
|
||||
<div className="text-red-500 text-xl">Expired</div>
|
||||
)}
|
||||
{!methods && (
|
||||
<div>
|
||||
<AsyncButton onClick={loadPaymentMethods}>Extend Now</AsyncButton>
|
||||
</div>
|
||||
)}
|
||||
{methods && !method && (
|
||||
<>
|
||||
<div className="text-xl">Payment Method:</div>
|
||||
{methods.map((v) => paymentMethod(v))}
|
||||
</>
|
||||
)}
|
||||
{payment && (
|
||||
<>
|
||||
<h3>Renew VPS</h3>
|
||||
<VpsPayment
|
||||
payment={payment}
|
||||
onPaid={async () => {
|
||||
setPayment(undefined);
|
||||
onPaid();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
185
src/pages/vm-graphs.tsx
Normal file
185
src/pages/vm-graphs.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { TimeSeriesData, VmInstance } from "../api";
|
||||
import useLogin from "../hooks/login";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
LineChart,
|
||||
Line,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
|
||||
export function VmGraphsPage() {
|
||||
const { state } = useLocation() as { state?: VmInstance };
|
||||
const login = useLogin();
|
||||
const [data, setData] = useState<Array<TimeSeriesData>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) return;
|
||||
login?.api.getVmTimeSeries(state.id).then(setData);
|
||||
}, [login]);
|
||||
|
||||
const maxRam =
|
||||
data?.reduce((acc, v) => {
|
||||
const mb = v.memory_size / 1024 / 1024;
|
||||
return acc < mb ? mb : acc;
|
||||
}, 0) ?? 0;
|
||||
|
||||
const KB = 1024;
|
||||
const MB = 1024 * 1024;
|
||||
function scaleLabel(v: number) {
|
||||
switch (v) {
|
||||
case MB:
|
||||
return "MiB";
|
||||
case KB:
|
||||
return "KiB";
|
||||
}
|
||||
return "B";
|
||||
}
|
||||
const net_scale =
|
||||
data?.reduce((acc, v) => {
|
||||
const b = Math.max(v.net_in, v.net_out);
|
||||
if (b > MB && b > acc) {
|
||||
return MB;
|
||||
} else if (b > KB && b > acc) {
|
||||
return KB;
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
}, 0) ?? 0;
|
||||
const net_scale_label = scaleLabel(net_scale);
|
||||
const disk_scale =
|
||||
data?.reduce((acc, v) => {
|
||||
const b = Math.max(v.disk_read, v.disk_write);
|
||||
if (b > MB && b > acc) {
|
||||
return MB;
|
||||
} else if (b > KB && b > acc) {
|
||||
return KB;
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
}, 0) ?? 0;
|
||||
const disk_scale_label = scaleLabel(disk_scale);
|
||||
const sortedData = (data ?? [])
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
.map((v) => ({
|
||||
timestamp: new Date(v.timestamp * 1000).toLocaleTimeString(),
|
||||
CPU: 100 * v.cpu,
|
||||
RAM: v.memory / 1024 / 1024,
|
||||
NET_IN: v.net_in / net_scale,
|
||||
NET_OUT: v.net_out / net_scale,
|
||||
DISK_READ: v.disk_read / disk_scale,
|
||||
DISK_WRITE: v.disk_write / disk_scale,
|
||||
}));
|
||||
const toolTip = (
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(200,200,200,0.5)" }}
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload as TimeSeriesData;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 bg-neutral-700 rounded-xl px-2 py-3">
|
||||
<div>{data.timestamp}</div>
|
||||
{payload.map((p) => (
|
||||
<div>
|
||||
{p.name}: {Number(p.value).toFixed(2)}
|
||||
{p.unit}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Link to={"/vm"} state={state}>
|
||||
< Back
|
||||
</Link>
|
||||
<h2>CPU</h2>
|
||||
<ResponsiveContainer height={200}>
|
||||
<LineChart
|
||||
data={sortedData}
|
||||
margin={{ left: 0, right: 0 }}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
<XAxis dataKey="timestamp" />
|
||||
<YAxis unit="%" domain={[0, 100]} />
|
||||
<Line type="monotone" dataKey="CPU" unit="%" dot={false} />
|
||||
{toolTip}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<h2>Memory</h2>
|
||||
<ResponsiveContainer height={200}>
|
||||
<LineChart
|
||||
data={sortedData}
|
||||
margin={{ left: 0, right: 0 }}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
<XAxis dataKey="timestamp" />
|
||||
<YAxis unit="MB" domain={[0, maxRam]} />
|
||||
<Line type="monotone" dataKey="RAM" unit="MB" dot={false} />
|
||||
{toolTip}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<h2>Network</h2>
|
||||
<ResponsiveContainer height={200}>
|
||||
<LineChart
|
||||
data={sortedData}
|
||||
margin={{ left: 20, right: 0 }}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
<XAxis dataKey="timestamp" />
|
||||
<YAxis unit={`${net_scale_label}/s`} domain={[0, "auto"]} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="NET_IN"
|
||||
unit={`${net_scale_label}/s`}
|
||||
stroke="red"
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="NET_OUT"
|
||||
unit={`${net_scale_label}/s`}
|
||||
stroke="green"
|
||||
dot={false}
|
||||
/>
|
||||
{toolTip}
|
||||
<Legend />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<h2>Disk</h2>
|
||||
<ResponsiveContainer height={200}>
|
||||
<LineChart
|
||||
data={sortedData}
|
||||
margin={{ left: 20, right: 0 }}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
<XAxis dataKey="timestamp" />
|
||||
<YAxis unit={`${disk_scale_label}/s`} domain={[0, "auto"]} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="DISK_READ"
|
||||
unit={`${disk_scale_label}/s`}
|
||||
stroke="red"
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="DISK_WRITE"
|
||||
unit={`${disk_scale_label}/s`}
|
||||
stroke="green"
|
||||
dot={false}
|
||||
/>
|
||||
{toolTip}
|
||||
<Legend />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
179
src/pages/vm.tsx
179
src/pages/vm.tsx
@ -1,12 +1,10 @@
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { VmInstance, VmIpAssignment, VmPayment } from "../api";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { VmInstance, VmIpAssignment } from "../api";
|
||||
import VpsInstanceRow from "../components/vps-instance";
|
||||
import useLogin from "../hooks/login";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import VpsPayment from "../components/vps-payment";
|
||||
import CostLabel from "../components/cost";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AsyncButton } from "../components/button";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
@ -19,11 +17,10 @@ const fit = new FitAddon();
|
||||
|
||||
export default function VmPage() {
|
||||
const location = useLocation() as { state?: VmInstance };
|
||||
const { action } = useParams();
|
||||
const login = useLogin();
|
||||
const navigate = useNavigate();
|
||||
const [state, setState] = useState<VmInstance | undefined>(location?.state);
|
||||
const [payment, setPayment] = useState<VmPayment>();
|
||||
|
||||
const [term] = useState<Terminal>();
|
||||
const termRef = useRef<HTMLDivElement | null>(null);
|
||||
const [editKey, setEditKey] = useState(false);
|
||||
@ -31,15 +28,6 @@ export default function VmPage() {
|
||||
const [error, setError] = useState<string>();
|
||||
const [key, setKey] = useState(state?.ssh_key?.id ?? -1);
|
||||
|
||||
const renew = useCallback(
|
||||
async function () {
|
||||
if (!login?.api || !state) return;
|
||||
const p = await login?.api.renewVm(state.id);
|
||||
setPayment(p);
|
||||
},
|
||||
[login?.api, state],
|
||||
);
|
||||
|
||||
async function reloadVmState() {
|
||||
if (!state) return;
|
||||
const newState = await login?.api.getVm(state.id);
|
||||
@ -47,35 +35,55 @@ export default function VmPage() {
|
||||
}
|
||||
|
||||
function ipRow(a: VmIpAssignment, reverse: boolean) {
|
||||
return <div
|
||||
key={a.id}
|
||||
className="bg-neutral-900 px-2 py-3 rounded-lg flex gap-2 flex-col justify-center"
|
||||
>
|
||||
<div>
|
||||
<span className="select-none">IP: </span>
|
||||
<span className="select-all">{a.ip.split("/")[0]}</span>
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className="bg-neutral-900 px-2 py-3 rounded-lg flex gap-2 flex-col justify-center"
|
||||
>
|
||||
<div>
|
||||
<span className="select-none">IP: </span>
|
||||
<span className="select-all">{a.ip.split("/")[0]}</span>
|
||||
</div>
|
||||
{a.forward_dns && (
|
||||
<div className="text-sm select-none">
|
||||
DNS: <span className="select-all">{a.forward_dns}</span>
|
||||
</div>
|
||||
)}
|
||||
{reverse && (
|
||||
<div className="text-sm select-none flex items-center gap-2">
|
||||
<div>
|
||||
PTR: <span className="select-all">{a.reverse_dns}</span>
|
||||
</div>
|
||||
<Icon
|
||||
name="pencil"
|
||||
className="inline"
|
||||
size={15}
|
||||
onClick={() => setEditReverse(a)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{a.forward_dns && <div className="text-sm select-none">DNS: <span className="select-all">{a.forward_dns}</span></div>}
|
||||
{reverse && <div className="text-sm select-none flex items-center gap-2">
|
||||
<div>PTR: <span className="select-all">{a.reverse_dns}</span></div>
|
||||
<Icon name="pencil" className="inline" size={15} onClick={() => setEditReverse(a)} />
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function networkInfo() {
|
||||
if (!state) return;
|
||||
if ((state.ip_assignments?.length ?? 0) === 0) {
|
||||
return <div className="text-sm text-red-500">No IP's assigned</div>
|
||||
return <div className="text-sm text-red-500">No IP's assigned</div>;
|
||||
}
|
||||
return <>
|
||||
{state.ip_assignments?.map(i => ipRow(i, true))}
|
||||
{ipRow({
|
||||
id: -1,
|
||||
ip: toEui64("2a13:2c0::", state.mac_address),
|
||||
gateway: ""
|
||||
}, false)}
|
||||
</>
|
||||
return (
|
||||
<>
|
||||
{state.ip_assignments?.map((i) => ipRow(i, true))}
|
||||
{ipRow(
|
||||
{
|
||||
id: -1,
|
||||
ip: toEui64("2a13:2c0::", state.mac_address),
|
||||
gateway: "",
|
||||
},
|
||||
false,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/*async function openTerminal() {
|
||||
@ -104,17 +112,10 @@ export default function VmPage() {
|
||||
}
|
||||
}, [termRef, term, fit]);
|
||||
|
||||
useEffect(() => {
|
||||
switch (action) {
|
||||
case "renew":
|
||||
renew();
|
||||
}
|
||||
}, [renew, action]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => reloadVmState(), 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
if (!state) {
|
||||
return <h2>No VM selected</h2>;
|
||||
@ -123,60 +124,34 @@ export default function VmPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<VpsInstanceRow vm={state} actions={true} />
|
||||
{action === undefined && (
|
||||
<>
|
||||
<div className="text-xl">Network:</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{networkInfo()}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="text-xl">SSH Key:</div>
|
||||
<div className="text-sm bg-neutral-900 px-3 py-1 rounded-lg">
|
||||
{state.ssh_key?.name}
|
||||
</div>
|
||||
<Icon name="pencil" onClick={() => setEditKey(true)} />
|
||||
</div>
|
||||
<hr />
|
||||
<div className="text-xl">Renewal</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>{new Date(state.expires).toDateString()}</div>
|
||||
{state.template?.cost_plan && (
|
||||
<div>
|
||||
<CostLabel cost={state.template?.cost_plan} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AsyncButton onClick={() => navigate("/vm/renew", { state })}>
|
||||
Extend Now
|
||||
</AsyncButton>
|
||||
{/*
|
||||
|
||||
<div className="text-xl">Network:</div>
|
||||
<div className="grid grid-cols-2 gap-4">{networkInfo()}</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="text-xl">SSH Key:</div>
|
||||
<div className="text-sm bg-neutral-900 px-3 py-1 rounded-lg">
|
||||
{state.ssh_key?.name}
|
||||
</div>
|
||||
<Icon name="pencil" onClick={() => setEditKey(true)} />
|
||||
</div>
|
||||
<hr />
|
||||
<div className="flex gap-4">
|
||||
<AsyncButton onClick={() => navigate("/vm/billing", { state })}>
|
||||
Billing
|
||||
</AsyncButton>
|
||||
<AsyncButton onClick={() => navigate("/vm/graphs", { state })}>
|
||||
Graphs
|
||||
</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 () => {
|
||||
setPayment(undefined);
|
||||
if (!login?.api || !state) return;
|
||||
navigate("/vm", {
|
||||
state
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{editKey && (
|
||||
<Modal id="edit-ssh-key" onClose={() => setEditKey(false)}>
|
||||
<SSHKeySelector selectedKey={key} setSelectedKey={setKey} />
|
||||
<div className="flex flex-col gap-4 mt-8">
|
||||
<small>After selecting a new key, please restart the VM.</small>
|
||||
{error && <b className="text-red-700">{error}</b>}
|
||||
{error && <b className="text-red-500">{error}</b>}
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
setError(undefined);
|
||||
@ -201,15 +176,21 @@ export default function VmPage() {
|
||||
)}
|
||||
{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
|
||||
})} />
|
||||
<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-700">{error}</b>}
|
||||
{error && <b className="text-red-500">{error}</b>}
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
setError(undefined);
|
||||
|
49
src/ref.ts
49
src/ref.ts
@ -1,34 +1,37 @@
|
||||
export interface RefCode {
|
||||
code: string;
|
||||
saved: number;
|
||||
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 = "";
|
||||
}
|
||||
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;
|
||||
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");
|
||||
}
|
||||
window.localStorage.removeItem("ref");
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
362
yarn.lock
362
yarn.lock
@ -204,6 +204,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7":
|
||||
version: 7.26.9
|
||||
resolution: "@babel/runtime@npm:7.26.9"
|
||||
dependencies:
|
||||
regenerator-runtime: "npm:^0.14.0"
|
||||
checksum: 10c0/e8517131110a6ec3a7360881438b85060e49824e007f4a64b5dfa9192cf2bb5c01e84bfc109f02d822c7edb0db926928dd6b991e3ee460b483fb0fac43152d9b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/template@npm:^7.25.0":
|
||||
version: 7.25.0
|
||||
resolution: "@babel/template@npm:7.25.0"
|
||||
@ -611,6 +620,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@revolut/checkout@npm:^1.1.20":
|
||||
version: 1.1.20
|
||||
resolution: "@revolut/checkout@npm:1.1.20"
|
||||
checksum: 10c0/8c1053a434ff759a4101f757e08a341331993e4be210fdd6c5def7e452ead9710dba300efe30fd8570a6686bd445ecd15716f5a5c93996a6d60713946e6317a0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-android-arm-eabi@npm:4.20.0":
|
||||
version: 4.20.0
|
||||
resolution: "@rollup/rollup-android-arm-eabi@npm:4.20.0"
|
||||
@ -882,6 +898,75 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-array@npm:^3.0.3":
|
||||
version: 3.2.1
|
||||
resolution: "@types/d3-array@npm:3.2.1"
|
||||
checksum: 10c0/38bf2c778451f4b79ec81a2288cb4312fe3d6449ecdf562970cc339b60f280f31c93a024c7ff512607795e79d3beb0cbda123bb07010167bce32927f71364bca
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-color@npm:*":
|
||||
version: 3.1.3
|
||||
resolution: "@types/d3-color@npm:3.1.3"
|
||||
checksum: 10c0/65eb0487de606eb5ad81735a9a5b3142d30bc5ea801ed9b14b77cb14c9b909f718c059f13af341264ee189acf171508053342142bdf99338667cea26a2d8d6ae
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-ease@npm:^3.0.0":
|
||||
version: 3.0.2
|
||||
resolution: "@types/d3-ease@npm:3.0.2"
|
||||
checksum: 10c0/aff5a1e572a937ee9bff6465225d7ba27d5e0c976bd9eacdac2e6f10700a7cb0c9ea2597aff6b43a6ed850a3210030870238894a77ec73e309b4a9d0333f099c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-interpolate@npm:^3.0.1":
|
||||
version: 3.0.4
|
||||
resolution: "@types/d3-interpolate@npm:3.0.4"
|
||||
dependencies:
|
||||
"@types/d3-color": "npm:*"
|
||||
checksum: 10c0/066ebb8da570b518dd332df6b12ae3b1eaa0a7f4f0c702e3c57f812cf529cc3500ec2aac8dc094f31897790346c6b1ebd8cd7a077176727f4860c2b181a65ca4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-path@npm:*":
|
||||
version: 3.1.1
|
||||
resolution: "@types/d3-path@npm:3.1.1"
|
||||
checksum: 10c0/2c36eb31ebaf2ce4712e793fd88087117976f7c4ed69cc2431825f999c8c77cca5cea286f3326432b770739ac6ccd5d04d851eb65e7a4dbcc10c982b49ad2c02
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-scale@npm:^4.0.2":
|
||||
version: 4.0.9
|
||||
resolution: "@types/d3-scale@npm:4.0.9"
|
||||
dependencies:
|
||||
"@types/d3-time": "npm:*"
|
||||
checksum: 10c0/4ac44233c05cd50b65b33ecb35d99fdf07566bcdbc55bc1306b2f27d1c5134d8c560d356f2c8e76b096e9125ffb8d26d95f78d56e210d1c542cb255bdf31d6c8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-shape@npm:^3.1.0":
|
||||
version: 3.1.7
|
||||
resolution: "@types/d3-shape@npm:3.1.7"
|
||||
dependencies:
|
||||
"@types/d3-path": "npm:*"
|
||||
checksum: 10c0/38e59771c1c4c83b67aa1f941ce350410522a149d2175832fdc06396b2bb3b2c1a2dd549e0f8230f9f24296ee5641a515eaf10f55ee1ef6c4f83749e2dd7dcfd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-time@npm:*, @types/d3-time@npm:^3.0.0":
|
||||
version: 3.0.4
|
||||
resolution: "@types/d3-time@npm:3.0.4"
|
||||
checksum: 10c0/6d9e2255d63f7a313a543113920c612e957d70da4fb0890931da6c2459010291b8b1f95e149a538500c1c99e7e6c89ffcce5554dd29a31ff134a38ea94b6d174
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-timer@npm:^3.0.0":
|
||||
version: 3.0.2
|
||||
resolution: "@types/d3-timer@npm:3.0.2"
|
||||
checksum: 10c0/c644dd9571fcc62b1aa12c03bcad40571553020feeb5811f1d8a937ac1e65b8a04b759b4873aef610e28b8714ac71c9885a4d6c127a048d95118f7e5b506d9e1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/estree@npm:1.0.5":
|
||||
version: 1.0.5
|
||||
resolution: "@types/estree@npm:1.0.5"
|
||||
@ -1389,6 +1474,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"clsx@npm:^2.0.0":
|
||||
version: 2.1.1
|
||||
resolution: "clsx@npm:2.1.1"
|
||||
checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"color-convert@npm:^1.9.0":
|
||||
version: 1.9.3
|
||||
resolution: "color-convert@npm:1.9.3"
|
||||
@ -1476,6 +1568,99 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.1.6":
|
||||
version: 3.2.4
|
||||
resolution: "d3-array@npm:3.2.4"
|
||||
dependencies:
|
||||
internmap: "npm:1 - 2"
|
||||
checksum: 10c0/08b95e91130f98c1375db0e0af718f4371ccacef7d5d257727fe74f79a24383e79aba280b9ffae655483ffbbad4fd1dec4ade0119d88c4749f388641c8bf8c50
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-color@npm:1 - 3":
|
||||
version: 3.1.0
|
||||
resolution: "d3-color@npm:3.1.0"
|
||||
checksum: 10c0/a4e20e1115fa696fce041fbe13fbc80dc4c19150fa72027a7c128ade980bc0eeeba4bcf28c9e21f0bce0e0dbfe7ca5869ef67746541dcfda053e4802ad19783c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-ease@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "d3-ease@npm:3.0.1"
|
||||
checksum: 10c0/fec8ef826c0cc35cda3092c6841e07672868b1839fcaf556e19266a3a37e6bc7977d8298c0fcb9885e7799bfdcef7db1baaba9cd4dcf4bc5e952cf78574a88b0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-format@npm:1 - 3":
|
||||
version: 3.1.0
|
||||
resolution: "d3-format@npm:3.1.0"
|
||||
checksum: 10c0/049f5c0871ebce9859fc5e2f07f336b3c5bfff52a2540e0bac7e703fce567cd9346f4ad1079dd18d6f1e0eaa0599941c1810898926f10ac21a31fd0a34b4aa75
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "d3-interpolate@npm:3.0.1"
|
||||
dependencies:
|
||||
d3-color: "npm:1 - 3"
|
||||
checksum: 10c0/19f4b4daa8d733906671afff7767c19488f51a43d251f8b7f484d5d3cfc36c663f0a66c38fe91eee30f40327443d799be17169f55a293a3ba949e84e57a33e6a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-path@npm:^3.1.0":
|
||||
version: 3.1.0
|
||||
resolution: "d3-path@npm:3.1.0"
|
||||
checksum: 10c0/dc1d58ec87fa8319bd240cf7689995111a124b141428354e9637aa83059eb12e681f77187e0ada5dedfce346f7e3d1f903467ceb41b379bfd01cd8e31721f5da
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-scale@npm:^4.0.2":
|
||||
version: 4.0.2
|
||||
resolution: "d3-scale@npm:4.0.2"
|
||||
dependencies:
|
||||
d3-array: "npm:2.10.0 - 3"
|
||||
d3-format: "npm:1 - 3"
|
||||
d3-interpolate: "npm:1.2.0 - 3"
|
||||
d3-time: "npm:2.1.1 - 3"
|
||||
d3-time-format: "npm:2 - 4"
|
||||
checksum: 10c0/65d9ad8c2641aec30ed5673a7410feb187a224d6ca8d1a520d68a7d6eac9d04caedbff4713d1e8545be33eb7fec5739983a7ab1d22d4e5ad35368c6729d362f1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-shape@npm:^3.1.0":
|
||||
version: 3.2.0
|
||||
resolution: "d3-shape@npm:3.2.0"
|
||||
dependencies:
|
||||
d3-path: "npm:^3.1.0"
|
||||
checksum: 10c0/f1c9d1f09926daaf6f6193ae3b4c4b5521e81da7d8902d24b38694517c7f527ce3c9a77a9d3a5722ad1e3ff355860b014557b450023d66a944eabf8cfde37132
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-time-format@npm:2 - 4":
|
||||
version: 4.1.0
|
||||
resolution: "d3-time-format@npm:4.1.0"
|
||||
dependencies:
|
||||
d3-time: "npm:1 - 3"
|
||||
checksum: 10c0/735e00fb25a7fd5d418fac350018713ae394eefddb0d745fab12bbff0517f9cdb5f807c7bbe87bb6eeb06249662f8ea84fec075f7d0cd68609735b2ceb29d206
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:^3.0.0":
|
||||
version: 3.1.0
|
||||
resolution: "d3-time@npm:3.1.0"
|
||||
dependencies:
|
||||
d3-array: "npm:2 - 3"
|
||||
checksum: 10c0/a984f77e1aaeaa182679b46fbf57eceb6ebdb5f67d7578d6f68ef933f8eeb63737c0949991618a8d29472dbf43736c7d7f17c452b2770f8c1271191cba724ca1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-timer@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "d3-timer@npm:3.0.1"
|
||||
checksum: 10c0/d4c63cb4bb5461d7038aac561b097cd1c5673969b27cbdd0e87fa48d9300a538b9e6f39b4a7f0e3592ef4f963d858c8a9f0e92754db73116770856f2fc04561a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4":
|
||||
version: 4.3.6
|
||||
resolution: "debug@npm:4.3.6"
|
||||
@ -1488,6 +1673,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"decimal.js-light@npm:^2.4.1":
|
||||
version: 2.5.1
|
||||
resolution: "decimal.js-light@npm:2.5.1"
|
||||
checksum: 10c0/4fd33f535aac9e5bd832796831b65d9ec7914ad129c7437b3ab991b0c2eaaa5a57e654e6174c4a17f1b3895ea366f0c1ab4955cdcdf7cfdcf3ad5a58b456c020
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"deep-is@npm:^0.1.3":
|
||||
version: 0.1.4
|
||||
resolution: "deep-is@npm:0.1.4"
|
||||
@ -1518,6 +1710,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dom-helpers@npm:^5.0.1":
|
||||
version: 5.2.1
|
||||
resolution: "dom-helpers@npm:5.2.1"
|
||||
dependencies:
|
||||
"@babel/runtime": "npm:^7.8.7"
|
||||
csstype: "npm:^3.0.2"
|
||||
checksum: 10c0/f735074d66dd759b36b158fa26e9d00c9388ee0e8c9b16af941c38f014a37fc80782de83afefd621681b19ac0501034b4f1c4a3bff5caa1b8667f0212b5e124c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eastasianwidth@npm:^0.2.0":
|
||||
version: 0.2.0
|
||||
resolution: "eastasianwidth@npm:0.2.0"
|
||||
@ -1799,6 +2001,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eventemitter3@npm:^4.0.1":
|
||||
version: 4.0.7
|
||||
resolution: "eventemitter3@npm:4.0.7"
|
||||
checksum: 10c0/5f6d97cbcbac47be798e6355e3a7639a84ee1f7d9b199a07017f1d2f1e2fe236004d14fa5dfaeba661f94ea57805385e326236a6debbc7145c8877fbc0297c6b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eventemitter3@npm:^5.0.1":
|
||||
version: 5.0.1
|
||||
resolution: "eventemitter3@npm:5.0.1"
|
||||
@ -1820,6 +2029,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fast-equals@npm:^5.0.1":
|
||||
version: 5.2.2
|
||||
resolution: "fast-equals@npm:5.2.2"
|
||||
checksum: 10c0/2bfeac6317a8959a00e2134749323557e5df6dea3af24e4457297733eace8ce4313fcbca2cf4532f3a6792607461e80442cd8d3af148d5c2e4e98ad996d6e5b5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.0":
|
||||
version: 3.3.2
|
||||
resolution: "fast-glob@npm:3.3.2"
|
||||
@ -2142,6 +2358,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"internmap@npm:1 - 2":
|
||||
version: 2.0.3
|
||||
resolution: "internmap@npm:2.0.3"
|
||||
checksum: 10c0/8cedd57f07bbc22501516fbfc70447f0c6812871d471096fad9ea603516eacc2137b633633daf432c029712df0baefd793686388ddf5737e3ea15074b877f7ed
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ip-address@npm:^9.0.5":
|
||||
version: 9.0.5
|
||||
resolution: "ip-address@npm:9.0.5"
|
||||
@ -2228,6 +2451,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"iso-3166-1@npm:^2.1.1":
|
||||
version: 2.1.1
|
||||
resolution: "iso-3166-1@npm:2.1.1"
|
||||
checksum: 10c0/65daf0283d22b2848d733a50ba6116a01df3e4d77970bb31d0df0696b4b386bdad0946dea53c9bb1e748e5e5e74608c07b6f4fe044908a5cffbf46f315385400
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"isomorphic-ws@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "isomorphic-ws@npm:5.0.0"
|
||||
@ -2377,6 +2607,7 @@ __metadata:
|
||||
resolution: "lnvps_web@workspace:."
|
||||
dependencies:
|
||||
"@eslint/js": "npm:^9.8.0"
|
||||
"@revolut/checkout": "npm:^1.1.20"
|
||||
"@scure/base": "npm:^1.2.1"
|
||||
"@snort/shared": "npm:^1.0.17"
|
||||
"@snort/system": "npm:^1.6.1"
|
||||
@ -2394,6 +2625,7 @@ __metadata:
|
||||
eslint-plugin-react-hooks: "npm:^5.1.0-rc.0"
|
||||
eslint-plugin-react-refresh: "npm:^0.4.9"
|
||||
globals: "npm:^15.9.0"
|
||||
iso-3166-1: "npm:^2.1.1"
|
||||
marked: "npm:^15.0.7"
|
||||
postcss: "npm:^8.4.41"
|
||||
prettier: "npm:^3.3.3"
|
||||
@ -2401,6 +2633,7 @@ __metadata:
|
||||
react: "npm:^18.3.1"
|
||||
react-dom: "npm:^18.3.1"
|
||||
react-router-dom: "npm:^7.0.1"
|
||||
recharts: "npm:^2.15.1"
|
||||
tailwindcss: "npm:^3.4.8"
|
||||
typescript: "npm:^5.5.3"
|
||||
typescript-eslint: "npm:^8.0.0"
|
||||
@ -2424,7 +2657,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loose-envify@npm:^1.1.0":
|
||||
"lodash@npm:^4.17.21":
|
||||
version: 4.17.21
|
||||
resolution: "lodash@npm:4.17.21"
|
||||
checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0":
|
||||
version: 1.4.0
|
||||
resolution: "loose-envify@npm:1.4.0"
|
||||
dependencies:
|
||||
@ -2708,7 +2948,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"object-assign@npm:^4.0.1":
|
||||
"object-assign@npm:^4.0.1, object-assign@npm:^4.1.1":
|
||||
version: 4.1.1
|
||||
resolution: "object-assign@npm:4.1.1"
|
||||
checksum: 10c0/1f4df9945120325d041ccf7b86f31e8bcc14e73d29171e37a7903050e96b81323784ec59f93f102ec635bcf6fa8034ba3ea0a8c7e69fa202b87ae3b6cec5a414
|
||||
@ -2959,6 +3199,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prop-types@npm:^15.6.2, prop-types@npm:^15.8.1":
|
||||
version: 15.8.1
|
||||
resolution: "prop-types@npm:15.8.1"
|
||||
dependencies:
|
||||
loose-envify: "npm:^1.4.0"
|
||||
object-assign: "npm:^4.1.1"
|
||||
react-is: "npm:^16.13.1"
|
||||
checksum: 10c0/59ece7ca2fb9838031d73a48d4becb9a7cc1ed10e610517c7d8f19a1e02fa47f7c27d557d8a5702bec3cfeccddc853579832b43f449e54635803f277b1c78077
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"punycode@npm:^2.1.0":
|
||||
version: 2.3.1
|
||||
resolution: "punycode@npm:2.3.1"
|
||||
@ -3001,6 +3252,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-is@npm:^16.13.1":
|
||||
version: 16.13.1
|
||||
resolution: "react-is@npm:16.13.1"
|
||||
checksum: 10c0/33977da7a5f1a287936a0c85639fec6ca74f4f15ef1e59a6bc20338fc73dc69555381e211f7a3529b8150a1f71e4225525b41b60b52965bda53ce7d47377ada1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-is@npm:^18.3.1":
|
||||
version: 18.3.1
|
||||
resolution: "react-is@npm:18.3.1"
|
||||
checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-refresh@npm:^0.14.2":
|
||||
version: 0.14.2
|
||||
resolution: "react-refresh@npm:0.14.2"
|
||||
@ -3038,6 +3303,35 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-smooth@npm:^4.0.4":
|
||||
version: 4.0.4
|
||||
resolution: "react-smooth@npm:4.0.4"
|
||||
dependencies:
|
||||
fast-equals: "npm:^5.0.1"
|
||||
prop-types: "npm:^15.8.1"
|
||||
react-transition-group: "npm:^4.4.5"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/d94cb27f808721ec040d320ca1927919199495fd212e54eb9dc8ee3f73ff1d808a34be9f4b09fe49b01f411ac2387fdf0e4bee297f18faf56f94bfbef5fd204c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-transition-group@npm:^4.4.5":
|
||||
version: 4.4.5
|
||||
resolution: "react-transition-group@npm:4.4.5"
|
||||
dependencies:
|
||||
"@babel/runtime": "npm:^7.5.5"
|
||||
dom-helpers: "npm:^5.0.1"
|
||||
loose-envify: "npm:^1.4.0"
|
||||
prop-types: "npm:^15.6.2"
|
||||
peerDependencies:
|
||||
react: ">=16.6.0"
|
||||
react-dom: ">=16.6.0"
|
||||
checksum: 10c0/2ba754ba748faefa15f87c96dfa700d5525054a0141de8c75763aae6734af0740e77e11261a1e8f4ffc08fd9ab78510122e05c21c2d79066c38bb6861a886c82
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react@npm:^18.2.0, react@npm:^18.3.1":
|
||||
version: 18.3.1
|
||||
resolution: "react@npm:18.3.1"
|
||||
@ -3065,6 +3359,41 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"recharts-scale@npm:^0.4.4":
|
||||
version: 0.4.5
|
||||
resolution: "recharts-scale@npm:0.4.5"
|
||||
dependencies:
|
||||
decimal.js-light: "npm:^2.4.1"
|
||||
checksum: 10c0/64ce1fc4ebe62001787bf4dc4cbb779452d33831619309c71c50277c58e8968ffe98941562d9d0d5ffdb02588ebd62f4fe6548fa826110fd458db9c3cc6dadc1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"recharts@npm:^2.15.1":
|
||||
version: 2.15.1
|
||||
resolution: "recharts@npm:2.15.1"
|
||||
dependencies:
|
||||
clsx: "npm:^2.0.0"
|
||||
eventemitter3: "npm:^4.0.1"
|
||||
lodash: "npm:^4.17.21"
|
||||
react-is: "npm:^18.3.1"
|
||||
react-smooth: "npm:^4.0.4"
|
||||
recharts-scale: "npm:^0.4.4"
|
||||
tiny-invariant: "npm:^1.3.1"
|
||||
victory-vendor: "npm:^36.6.8"
|
||||
peerDependencies:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/1ec3cea442382fe986500b5cc95ef3d5ee194a1cb244fa0fe288b5474c0aa501f89ed39590e1a5fec6ecdfd7ab92bb1e986699a66688423608b75b2559a865b3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"regenerator-runtime@npm:^0.14.0":
|
||||
version: 0.14.1
|
||||
resolution: "regenerator-runtime@npm:0.14.1"
|
||||
checksum: 10c0/1b16eb2c4bceb1665c89de70dcb64126a22bc8eb958feef3cd68fe11ac6d2a4899b5cd1b80b0774c7c03591dc57d16631a7f69d2daa2ec98100e2f29f7ec4cc4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"resolve-from@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "resolve-from@npm:4.0.0"
|
||||
@ -3468,6 +3797,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tiny-invariant@npm:^1.3.1":
|
||||
version: 1.3.3
|
||||
resolution: "tiny-invariant@npm:1.3.3"
|
||||
checksum: 10c0/65af4a07324b591a059b35269cd696aba21bef2107f29b9f5894d83cc143159a204b299553435b03874ebb5b94d019afa8b8eff241c8a4cfee95872c2e1c1c4a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"to-fast-properties@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "to-fast-properties@npm:2.0.0"
|
||||
@ -3607,6 +3943,28 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"victory-vendor@npm:^36.6.8":
|
||||
version: 36.9.2
|
||||
resolution: "victory-vendor@npm:36.9.2"
|
||||
dependencies:
|
||||
"@types/d3-array": "npm:^3.0.3"
|
||||
"@types/d3-ease": "npm:^3.0.0"
|
||||
"@types/d3-interpolate": "npm:^3.0.1"
|
||||
"@types/d3-scale": "npm:^4.0.2"
|
||||
"@types/d3-shape": "npm:^3.1.0"
|
||||
"@types/d3-time": "npm:^3.0.0"
|
||||
"@types/d3-timer": "npm:^3.0.0"
|
||||
d3-array: "npm:^3.1.6"
|
||||
d3-ease: "npm:^3.0.1"
|
||||
d3-interpolate: "npm:^3.0.1"
|
||||
d3-scale: "npm:^4.0.2"
|
||||
d3-shape: "npm:^3.1.0"
|
||||
d3-time: "npm:^3.0.0"
|
||||
d3-timer: "npm:^3.0.1"
|
||||
checksum: 10c0/bad36de3bf4d406834743c2e99a8281d786af324d7e84b7f7a2fc02c27a3779034fb0c3c4707d4c8e68683334d924a67100cfa13985235565e83b9877f8e2ffd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vite@npm:^5.4.0":
|
||||
version: 5.4.0
|
||||
resolution: "vite@npm:5.4.0"
|
||||
|
Reference in New Issue
Block a user