feat: custom status page
feat: logout
This commit is contained in:
@ -1 +1 @@
|
|||||||
VITE_API_URL="http://localhost:8000"
|
#VITE_API_URL="http://localhost:8000"
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
10
src/main.tsx
10
src/main.tsx
@ -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 />,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
52
src/pages/account-settings.tsx
Normal file
52
src/pages/account-settings.tsx
Normal 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()}
|
||||||
|
</>
|
||||||
|
}
|
@ -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 />
|
||||||
|
@ -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.
|
||||||
|
@ -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
49
src/pages/status.tsx
Normal 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>
|
||||||
|
}
|
@ -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
11
src/status.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"birth": "2024-06-05T00:00:00Z",
|
||||||
|
"events":[
|
||||||
|
{
|
||||||
|
"start_time": "2025-02-10T05:00:00Z",
|
||||||
|
"end_time": "2025-02-10T10:08:00Z",
|
||||||
|
"title": "VPS outage",
|
||||||
|
"description": "Primary disk full, causing system to halt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
Reference in New Issue
Block a user