feat: custom status page

feat: logout
This commit is contained in:
2025-02-26 11:54:10 +00:00
parent 912bb21022
commit 7af41a1480
12 changed files with 160 additions and 53 deletions

View File

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

View File

@ -16,6 +16,7 @@ export default function useLogin() {
publicKey: session.publicKey, publicKey: session.publicKey,
system, system,
api: new LNVpsApi(ApiUrl, LoginState.getSigner()), api: new LNVpsApi(ApiUrl, LoginState.getSigner()),
logout: () => LoginState.logout()
} }
: undefined, [session, system]); : undefined, [session, system]);
} }

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { VmTemplate, LNVpsApi } from "../api"; import { VmTemplate, LNVpsApi } from "../api";
import Profile from "../components/profile";
import VpsCard from "../components/vps-card"; import VpsCard from "../components/vps-card";
import { ApiUrl, NostrProfile } from "../const"; import { ApiUrl, NostrProfile } from "../const";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@ -15,31 +14,39 @@ export default function HomePage() {
return ( return (
<> <>
<h1>VPS Offers</h1> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-2"> <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"> <div className="grid grid-cols-3 gap-2">
{offers.map((a) => ( {offers.map((a) => (
<VpsCard spec={a} key={a.id} /> <VpsCard spec={a} key={a.id} />
))} ))}
</div> </div>
<small> <small className="text-neutral-400">
All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic
</small> </small>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<a target="_blank" href={`https://snort.social/${NostrProfile.encode()}`}>
<Profile link={NostrProfile} />
</a>
<div className="text-center"> <div className="text-center">
<a target="_blank" href="http://speedtest.v0l.io">
Speedtest
</a>
{" | "}
<a href="/lnvps.asc">PGP</a> <a href="/lnvps.asc">PGP</a>
{" | "} {" | "}
<a href="https://lnvps1.statuspage.io/" target="_blank">Status</a> <Link to="/status">Status</Link>
{" | "} {" | "}
<Link to="/tos">Terms</Link> <Link to="/tos">Terms</Link>
{" | "}
<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>
<div className="text-xs text-center text-neutral-400"> <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.

View File

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

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

@ -0,0 +1,49 @@
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,5 +1,5 @@
import Markdown from "../components/markdown"; import Markdown from "../components/markdown";
import TOS from "../../tos.md?raw"; import TOS from "../tos.md?raw";
export function TosPage() { export function TosPage() {
return <Markdown content={TOS} /> return <Markdown content={TOS} />

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