Compare commits

...

2 Commits

Author SHA1 Message Date
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
17 changed files with 471 additions and 251 deletions

View File

@ -1 +1 @@
VITE_API_URL="http://localhost:8000"
#VITE_API_URL="http://localhost:8000"

View File

@ -8,13 +8,18 @@ interface MarkdownProps {
content: string;
}
const Markdown = forwardRef<HTMLDivElement, MarkdownProps>((props: MarkdownProps, ref) => {
const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
(props: MarkdownProps, ref) => {
let ctr = 0;
function renderToken(t: Token): ReactNode {
try {
switch (t.type) {
case "paragraph": {
return <div key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</div>;
return (
<div key={ctr++}>
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</div>
);
}
case "image": {
return <img key={ctr++} src={t.href} />;
@ -22,17 +27,41 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>((props: MarkdownProps
case "heading": {
switch (t.depth) {
case 1:
return <h1 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h1>;
return (
<h1 key={ctr++}>
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h1>
);
case 2:
return <h2 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h2>;
return (
<h2 key={ctr++}>
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h2>
);
case 3:
return <h3 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h3>;
return (
<h3 key={ctr++}>
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h3>
);
case 4:
return <h4 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h4>;
return (
<h4 key={ctr++}>
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h4>
);
case 5:
return <h5 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h5>;
return (
<h5 key={ctr++}>
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h5>
);
case 6:
return <h6 key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h6>;
return (
<h6 key={ctr++}>
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h6>
);
}
throw new Error("Invalid heading");
}
@ -49,10 +78,16 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>((props: MarkdownProps
return <hr key={ctr++} />;
}
case "strong": {
return <b key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</b>;
return (
<b key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</b>
);
}
case "blockquote": {
return <blockquote key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</blockquote>;
return (
<blockquote key={ctr++}>
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</blockquote>
);
}
case "link": {
return (
@ -69,20 +104,30 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>((props: MarkdownProps
}
}
case "list_item": {
return <li key={ctr++}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</li>;
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>;
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>;
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 => (
{(t.header as Tokens.TableCell[]).map((v) => (
<th className="border" key={ctr++}>
{v.tokens ? v.tokens.map(renderToken) : v.text}
</th>
@ -90,7 +135,7 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>((props: MarkdownProps
</tr>
</thead>
<tbody>
{(t.rows as Tokens.TableCell[][]).map(v => (
{(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}>
@ -126,9 +171,12 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>((props: MarkdownProps
}, [props.content]);
return (
<div className="markdown" ref={ref}>
{parsed.filter(a => a.type !== "footnote" && a.type !== "footnotes").map(a => renderToken(a))}
{parsed
.filter((a) => a.type !== "footnote" && a.type !== "footnotes")
.map((a) => renderToken(a))}
</div>
);
});
},
);
export default Markdown;

View File

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

View File

@ -10,12 +10,17 @@ export default function useLogin() {
() => LoginState.snapshot(),
);
const system = useContext(SnortContext);
return useMemo(() => session
return useMemo(
() =>
session
? {
type: session.type,
publicKey: session.publicKey,
system,
api: new LNVpsApi(ApiUrl, LoginState.getSigner()),
logout: () => LoginState.logout(),
}
: undefined, [session, system]);
: undefined,
[session, system],
);
}

View File

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

View File

@ -11,6 +11,8 @@ import VmPage from "./pages/vm.tsx";
import AccountPage from "./pages/account.tsx";
import SignUpPage from "./pages/sign-up.tsx";
import { TosPage } from "./pages/terms.tsx";
import { StatusPage } from "./pages/status.tsx";
import { AccountSettings } from "./pages/account-settings.tsx";
const system = new NostrSystem({
automaticOutboxModel: false,
@ -39,6 +41,10 @@ const router = createBrowserRouter([
path: "/account",
element: <AccountPage />,
},
{
path: "/account/settings",
element: <AccountSettings />,
},
{
path: "/order",
element: <OrderPage />,
@ -50,7 +56,11 @@ const router = createBrowserRouter([
{
path: "/tos",
element: <TosPage />,
}
},
{
path: "/status",
element: <StatusPage />,
},
],
},
]);

View File

@ -0,0 +1,75 @@
import useLogin from "../hooks/login";
import { useEffect, useState } from "react";
import { AccountDetail } from "../api";
import { AsyncButton } from "../components/button";
import { Icon } from "../components/icon";
export function AccountSettings() {
const login = useLogin();
const [acc, setAcc] = useState<AccountDetail>();
const [editEmail, setEditEmail] = useState(false);
useEffect(() => {
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>
</>
);
}
return <>{notifications()}</>;
}

View File

@ -1,15 +1,14 @@
import { useEffect, useState } from "react";
import { AccountDetail, LNVpsApi, VmInstance } from "../api";
import { LNVpsApi, VmInstance } from "../api";
import useLogin from "../hooks/login";
import VpsInstanceRow from "../components/vps-instance";
import { hexToBech32 } from "@snort/shared";
import { Icon } from "../components/icon";
import { AsyncButton } from "../components/button";
import { useNavigate } from "react-router-dom";
export default function AccountPage() {
const login = useLogin();
const [acc, setAcc] = useState<AccountDetail>();
const [editEmail, setEditEmail] = useState(false);
const navigate = useNavigate();
const [vms, setVms] = useState<Array<VmInstance>>([]);
async function loadVms(api: LNVpsApi) {
@ -20,7 +19,6 @@ export default function AccountPage() {
useEffect(() => {
if (login?.api) {
loadVms(login.api);
login.api.getAccount().then(setAcc);
const t = setInterval(() => {
loadVms(login.api);
}, 5_000);
@ -28,64 +26,49 @@ export default function AccountPage() {
}
}, [login]);
function notifications() {
return <>
<h3>Notification Settings</h3>
<div className="flex gap-2 items-center">
<input type="checkbox" checked={acc?.contact_email ?? false} onChange={(e) => {
setAcc((s) => (s ? { ...s, contact_email: e.target.checked } : undefined));
}} />
Email
<input type="checkbox" checked={acc?.contact_nip17 ?? false} onChange={(e) => {
setAcc((s) => (s ? { ...s, contact_nip17: e.target.checked } : undefined));
}} />
Nostr DM
</div>
<div className="flex gap-2 items-center">
<h4>Email</h4>
<input type="text" disabled={!editEmail} value={acc?.email} onChange={e => setAcc(s => (s ? { ...s, email: e.target.value } : undefined))} />
{!editEmail && <Icon name="pencil" onClick={() => setEditEmail(true)} />}
</div>
<div>
<AsyncButton onClick={async () => {
if (login?.api && acc) {
await login.api.updateAccount(acc);
const newAcc = await login.api.getAccount();
setAcc(newAcc);
setEditEmail(false);
}
}}>
Save
</AsyncButton>
</div>
</>
}
const npub = hexToBech32("npub", login?.publicKey);
const subjectLine = `[${npub}] Account Query`;
return (
<div className="flex flex-col gap-2">
Your Public Key:
<pre className="bg-neutral-900 rounded-md px-3 py-2 select-all text-sm">{npub}</pre>
{notifications()}
<pre className="bg-neutral-900 rounded-md px-3 py-2 select-all text-sm">
{npub}
</pre>
<div className="flex justify-between">
<AsyncButton onClick={() => navigate("settings")}>Settings</AsyncButton>
<AsyncButton
onClick={() => {
login?.logout();
navigate("/");
}}
>
Logout
</AsyncButton>
</div>
<h3>My Resources</h3>
<div className="rounded-xl bg-red-400 text-black p-3">
Something doesnt look right? <br />
Please contact support on:{" "}
<a href={`mailto:sales@lnvps.net?subject=${encodeURIComponent(subjectLine)}`} className="underline">
<a
href={`mailto:sales@lnvps.net?subject=${encodeURIComponent(subjectLine)}`}
className="underline"
>
sales@lnvps.net
</a>
<br />
<b>Please include your public key in all communications.</b>
</div>
{vms.map((a) => (
<VpsInstanceRow key={a.id} vm={a} onReload={() => {
<VpsInstanceRow
key={a.id}
vm={a}
onReload={() => {
if (login?.api) {
loadVms(login.api);
}
}} />
}}
/>
))}
</div>
);
}

View File

@ -1,6 +1,5 @@
import { useState, useEffect } from "react";
import { VmTemplate, LNVpsApi } from "../api";
import Profile from "../components/profile";
import VpsCard from "../components/vps-card";
import { ApiUrl, NostrProfile } from "../const";
import { Link } from "react-router-dom";
@ -15,40 +14,52 @@ export default function HomePage() {
return (
<>
<h1>VPS Offers</h1>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-4">
<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} />
))}
</div>
<small>
<small className="text-neutral-400">
All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic
</small>
<div className="flex flex-col gap-6">
<a target="_blank" href={`https://snort.social/${NostrProfile.encode()}`}>
<Profile link={NostrProfile} />
</a>
<div className="text-center">
<a target="_blank" href="http://speedtest.v0l.io">
Speedtest
</a>
{" | "}
<a href="/lnvps.asc">PGP</a>
{" | "}
<a href="https://lnvps1.statuspage.io/" target="_blank">Status</a>
<Link to="/status">Status</Link>
{" | "}
<Link to="/tos">Terms</Link>
{" | "}
<a
href={`https://snort.social/${NostrProfile.encode()}`}
target="_blank"
>
Nostr
</a>
{" | "}
<a href="https://git.v0l.io/LNVPS" target="_blank">
Git
</a>
{" | "}
<a href="https://speedtest.v0l.io" target="_blank">
Speedtest
</a>
</div>
<div className="text-xs text-center text-neutral-400">
LNVPS is a trading name of Apex Strata Ltd, a company registered in Ireland.
LNVPS is a trading name of Apex Strata Ltd, a company registered in
Ireland.
<br />
Comany Number: 702423,
Address: Suite 10628, 26/27 Upper Pembroke Street, Dublin 2, D02 X361, Ireland
Comany Number: 702423, Address: Suite 10628, 26/27 Upper Pembroke
Street, Dublin 2, D02 X361, Ireland
</div>
</div>
</div>
</>
);

View File

@ -5,7 +5,9 @@ export default function Layout() {
return (
<div className="w-[700px] mx-auto m-2 p-2">
<div className="flex items-center justify-between mb-4">
<Link to="/">LNVPS</Link>
<Link to="/" className="text-2xl">
LNVPS
</Link>
<LoginButton />
</div>

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 TOS from "../../tos.md?raw";
import TOS from "../tos.md?raw";
export function TosPage() {
return <Markdown content={TOS} />
return <Markdown content={TOS} />;
}

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