This commit is contained in:
@ -116,7 +116,7 @@ export class LNVpsApi {
|
|||||||
constructor(
|
constructor(
|
||||||
readonly url: string,
|
readonly url: string,
|
||||||
readonly publisher: EventPublisher | undefined,
|
readonly publisher: EventPublisher | undefined,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
async getAccount() {
|
async getAccount() {
|
||||||
const { data } = await this.#handleResponse<ApiResponse<AccountDetail>>(
|
const { data } = await this.#handleResponse<ApiResponse<AccountDetail>>(
|
||||||
@ -147,9 +147,9 @@ export class LNVpsApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getVmTimeSeries(id: number) {
|
async getVmTimeSeries(id: number) {
|
||||||
const { data } = await this.#handleResponse<ApiResponse<Array<TimeSeriesData>>>(
|
const { data } = await this.#handleResponse<
|
||||||
await this.#req(`/api/v1/vm/${id}/time-series`, "GET"),
|
ApiResponse<Array<TimeSeriesData>>
|
||||||
);
|
>(await this.#req(`/api/v1/vm/${id}/time-series`, "GET"));
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,7 +61,7 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/vm/graphs",
|
path: "/vm/graphs",
|
||||||
element: <VmGraphsPage />
|
element: <VmGraphsPage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/tos",
|
path: "/tos",
|
||||||
|
@ -7,76 +7,77 @@ import { AsyncButton } from "../components/button";
|
|||||||
import CostLabel from "../components/cost";
|
import CostLabel from "../components/cost";
|
||||||
|
|
||||||
export function VmBillingPage() {
|
export function VmBillingPage() {
|
||||||
const location = useLocation() as { state?: VmInstance };
|
const location = useLocation() as { state?: VmInstance };
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [payment, setPayment] = useState<VmPayment>();
|
const [payment, setPayment] = useState<VmPayment>();
|
||||||
const [state, setState] = useState<VmInstance | undefined>(location?.state);
|
const [state, setState] = useState<VmInstance | undefined>(location?.state);
|
||||||
|
|
||||||
async function reloadVmState() {
|
|
||||||
if (!state) return;
|
|
||||||
const newState = await login?.api.getVm(state.id);
|
|
||||||
setState(newState);
|
|
||||||
return newState;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renew = useCallback(
|
|
||||||
async function () {
|
|
||||||
if (!login?.api || !state) return;
|
|
||||||
const p = await login?.api.renewVm(state.id);
|
|
||||||
setPayment(p);
|
|
||||||
},
|
|
||||||
[login?.api, state],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (params["action"] === "renew" && login && state) {
|
|
||||||
renew()
|
|
||||||
}
|
|
||||||
}, [login, state, params, renew]);
|
|
||||||
|
|
||||||
|
async function reloadVmState() {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const expireDate = new Date(state.expires);
|
const newState = await login?.api.getVm(state.id);
|
||||||
const days =
|
setState(newState);
|
||||||
(expireDate.getTime() - new Date().getTime()) / 1000 / 24 / 60 / 60;
|
return newState;
|
||||||
return (
|
}
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<Link to={"/vm"} state={state}>
|
const renew = useCallback(
|
||||||
< Back
|
async function () {
|
||||||
</Link>
|
if (!login?.api || !state) return;
|
||||||
<div className="text-xl bg-neutral-900 rounded-xl px-3 py-4 flex justify-between items-center">
|
const p = await login?.api.renewVm(state.id);
|
||||||
<div>Renewal for #{state.id}</div>
|
setPayment(p);
|
||||||
<CostLabel cost={state.template.cost_plan} />
|
},
|
||||||
</div>
|
[login?.api, state],
|
||||||
{days > 0 && (
|
);
|
||||||
<div>
|
|
||||||
Expires: {expireDate.toDateString()} ({Math.floor(days)} days)
|
useEffect(() => {
|
||||||
</div>
|
if (params["action"] === "renew" && login && state) {
|
||||||
)}
|
renew();
|
||||||
{days < 0 && params["action"] !== "renew"
|
}
|
||||||
&& <div className="text-red-500 text-xl">Expired</div>}
|
}, [login, state, params, renew]);
|
||||||
{!payment && (
|
|
||||||
<div>
|
if (!state) return;
|
||||||
<AsyncButton onClick={renew}>Extend Now</AsyncButton>
|
const expireDate = new Date(state.expires);
|
||||||
</div>
|
const days =
|
||||||
)}
|
(expireDate.getTime() - new Date().getTime()) / 1000 / 24 / 60 / 60;
|
||||||
{payment && (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4">
|
||||||
<h3>Renew VPS</h3>
|
<Link to={"/vm"} state={state}>
|
||||||
<VpsPayment
|
< Back
|
||||||
payment={payment}
|
</Link>
|
||||||
onPaid={async () => {
|
<div className="text-xl bg-neutral-900 rounded-xl px-3 py-4 flex justify-between items-center">
|
||||||
setPayment(undefined);
|
<div>Renewal for #{state.id}</div>
|
||||||
if (!login?.api || !state) return;
|
<CostLabel cost={state.template.cost_plan} />
|
||||||
const s = await reloadVmState();
|
</div>
|
||||||
if (params["action"] === "renew") {
|
{days > 0 && (
|
||||||
navigate("/vm", { state: s });
|
<div>
|
||||||
}
|
Expires: {expireDate.toDateString()} ({Math.floor(days)} days)
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
|
{days < 0 && params["action"] !== "renew" && (
|
||||||
|
<div className="text-red-500 text-xl">Expired</div>
|
||||||
|
)}
|
||||||
|
{!payment && (
|
||||||
|
<div>
|
||||||
|
<AsyncButton onClick={renew}>Extend Now</AsyncButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{payment && (
|
||||||
|
<>
|
||||||
|
<h3>Renew VPS</h3>
|
||||||
|
<VpsPayment
|
||||||
|
payment={payment}
|
||||||
|
onPaid={async () => {
|
||||||
|
setPayment(undefined);
|
||||||
|
if (!login?.api || !state) return;
|
||||||
|
const s = await reloadVmState();
|
||||||
|
if (params["action"] === "renew") {
|
||||||
|
navigate("/vm", { state: s });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,120 +2,184 @@ import { Link, useLocation } from "react-router-dom";
|
|||||||
import { TimeSeriesData, VmInstance } from "../api";
|
import { TimeSeriesData, VmInstance } from "../api";
|
||||||
import useLogin from "../hooks/login";
|
import useLogin from "../hooks/login";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ResponsiveContainer, XAxis, YAxis, Tooltip, LineChart, Line, Legend } from "recharts";
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
export function VmGraphsPage() {
|
export function VmGraphsPage() {
|
||||||
const { state } = useLocation() as { state?: VmInstance };
|
const { state } = useLocation() as { state?: VmInstance };
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const [data, setData] = useState<Array<TimeSeriesData>>();
|
const [data, setData] = useState<Array<TimeSeriesData>>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
login?.api.getVmTimeSeries(state.id).then(setData);
|
login?.api.getVmTimeSeries(state.id).then(setData);
|
||||||
}, [login]);
|
}, [login]);
|
||||||
|
|
||||||
const maxRam = data?.reduce((acc, v) => {
|
const maxRam =
|
||||||
const mb = v.memory_size / 1024 / 1024;
|
data?.reduce((acc, v) => {
|
||||||
return acc < mb ? mb : acc;
|
const mb = v.memory_size / 1024 / 1024;
|
||||||
|
return acc < mb ? mb : acc;
|
||||||
}, 0) ?? 0;
|
}, 0) ?? 0;
|
||||||
|
|
||||||
const KB = 1024;
|
const KB = 1024;
|
||||||
const MB = 1024 * 1024;
|
const MB = 1024 * 1024;
|
||||||
function scaleLabel(v: number) {
|
function scaleLabel(v: number) {
|
||||||
switch (net_scale) {
|
switch (net_scale) {
|
||||||
case MB: return "MiB";
|
case MB:
|
||||||
case KB: return "KiB";
|
return "MiB";
|
||||||
}
|
case KB:
|
||||||
return "B";
|
return "KiB";
|
||||||
}
|
}
|
||||||
const net_scale = data?.reduce((acc, v) => {
|
return "B";
|
||||||
const b = Math.max(v.net_in, v.net_out);
|
}
|
||||||
if (b > MB && b > acc) {
|
const net_scale =
|
||||||
return MB;
|
data?.reduce((acc, v) => {
|
||||||
} else if (b > KB && b > acc) {
|
const b = Math.max(v.net_in, v.net_out);
|
||||||
return KB;
|
if (b > MB && b > acc) {
|
||||||
} else {
|
return MB;
|
||||||
return acc;
|
} else if (b > KB && b > acc) {
|
||||||
}
|
return KB;
|
||||||
|
} else {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
}, 0) ?? 0;
|
}, 0) ?? 0;
|
||||||
const net_scale_label = scaleLabel(net_scale);
|
const net_scale_label = scaleLabel(net_scale);
|
||||||
const disk_scale = data?.reduce((acc, v) => {
|
const disk_scale =
|
||||||
const b = Math.max(v.disk_read, v.disk_write);
|
data?.reduce((acc, v) => {
|
||||||
if (b > MB && b > acc) {
|
const b = Math.max(v.disk_read, v.disk_write);
|
||||||
return MB;
|
if (b > MB && b > acc) {
|
||||||
} else if (b > KB && b > acc) {
|
return MB;
|
||||||
return KB;
|
} else if (b > KB && b > acc) {
|
||||||
} else {
|
return KB;
|
||||||
return acc;
|
} else {
|
||||||
}
|
return acc;
|
||||||
|
}
|
||||||
}, 0) ?? 0;
|
}, 0) ?? 0;
|
||||||
const disk_scale_label = scaleLabel(disk_scale);
|
const disk_scale_label = scaleLabel(disk_scale);
|
||||||
const sortedData = (data ?? [])
|
const sortedData = (data ?? [])
|
||||||
.sort((a, b) => a.timestamp - b.timestamp)
|
.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
.map((v) => ({
|
.map((v) => ({
|
||||||
timestamp: new Date(v.timestamp * 1000).toLocaleTimeString(),
|
timestamp: new Date(v.timestamp * 1000).toLocaleTimeString(),
|
||||||
CPU: 100 * v.cpu,
|
CPU: 100 * v.cpu,
|
||||||
RAM: v.memory / 1024 / 1024,
|
RAM: v.memory / 1024 / 1024,
|
||||||
NET_IN: v.net_in / net_scale,
|
NET_IN: v.net_in / net_scale,
|
||||||
NET_OUT: v.net_out / net_scale,
|
NET_OUT: v.net_out / net_scale,
|
||||||
DISK_READ: v.disk_read / disk_scale,
|
DISK_READ: v.disk_read / disk_scale,
|
||||||
DISK_WRITE: v.disk_write / disk_scale,
|
DISK_WRITE: v.disk_write / disk_scale,
|
||||||
}));
|
}));
|
||||||
const toolTip = <Tooltip cursor={{ fill: "rgba(200,200,200,0.5)" }}
|
const toolTip = (
|
||||||
content={({ active, payload }) => {
|
<Tooltip
|
||||||
if (active && payload && payload.length) {
|
cursor={{ fill: "rgba(200,200,200,0.5)" }}
|
||||||
const data = payload[0].payload as TimeSeriesData;
|
content={({ active, payload }) => {
|
||||||
return <div className="flex flex-col gap-2 bg-neutral-700 rounded-xl px-2 py-3">
|
if (active && payload && payload.length) {
|
||||||
<div>{data.timestamp}</div>
|
const data = payload[0].payload as TimeSeriesData;
|
||||||
{payload.map((p) => <div>{p.name}: {Number(p.value).toFixed(2)}{p.unit}</div>)}
|
return (
|
||||||
|
<div className="flex flex-col gap-2 bg-neutral-700 rounded-xl px-2 py-3">
|
||||||
</div>;
|
<div>{data.timestamp}</div>
|
||||||
}
|
{payload.map((p) => (
|
||||||
}} />;
|
<div>
|
||||||
return <div className="flex flex-col gap-4">
|
{p.name}: {Number(p.value).toFixed(2)}
|
||||||
<Link to={"/vm"} state={state}>
|
{p.unit}
|
||||||
< Back
|
</div>
|
||||||
</Link>
|
))}
|
||||||
<h2>CPU</h2>
|
</div>
|
||||||
<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}
|
return (
|
||||||
</LineChart>
|
<div className="flex flex-col gap-4">
|
||||||
</ResponsiveContainer>
|
<Link to={"/vm"} state={state}>
|
||||||
<h2>Memory</h2>
|
< Back
|
||||||
<ResponsiveContainer height={200}>
|
</Link>
|
||||||
<LineChart data={sortedData} margin={{ left: 0, right: 0 }} style={{ userSelect: "none" }}>
|
<h2>CPU</h2>
|
||||||
<XAxis dataKey="timestamp" />
|
<ResponsiveContainer height={200}>
|
||||||
<YAxis unit="MB" domain={[0, maxRam]} />
|
<LineChart
|
||||||
<Line type="monotone" dataKey="RAM" unit="MB" dot={false} />
|
data={sortedData}
|
||||||
{toolTip}
|
margin={{ left: 0, right: 0 }}
|
||||||
</LineChart>
|
style={{ userSelect: "none" }}
|
||||||
</ResponsiveContainer>
|
>
|
||||||
<h2>Network</h2>
|
<XAxis dataKey="timestamp" />
|
||||||
<ResponsiveContainer height={200}>
|
<YAxis unit="%" domain={[0, 100]} />
|
||||||
<LineChart data={sortedData} margin={{ left: 20, right: 0 }} style={{ userSelect: "none" }}>
|
<Line type="monotone" dataKey="CPU" unit="%" dot={false} />
|
||||||
<XAxis dataKey="timestamp" />
|
{toolTip}
|
||||||
<YAxis unit={`${net_scale_label}/s`} domain={[0, "auto"]} />
|
</LineChart>
|
||||||
<Line type="monotone" dataKey="NET_IN" unit={`${net_scale_label}/s`} stroke="red" dot={false} />
|
</ResponsiveContainer>
|
||||||
<Line type="monotone" dataKey="NET_OUT" unit={`${net_scale_label}/s`} stroke="green" dot={false} />
|
<h2>Memory</h2>
|
||||||
{toolTip}
|
<ResponsiveContainer height={200}>
|
||||||
<Legend />
|
<LineChart
|
||||||
</LineChart>
|
data={sortedData}
|
||||||
</ResponsiveContainer>
|
margin={{ left: 0, right: 0 }}
|
||||||
<h2>Disk</h2>
|
style={{ userSelect: "none" }}
|
||||||
<ResponsiveContainer height={200}>
|
>
|
||||||
<LineChart data={sortedData} margin={{ left: 20, right: 0 }} style={{ userSelect: "none" }}>
|
<XAxis dataKey="timestamp" />
|
||||||
<XAxis dataKey="timestamp" />
|
<YAxis unit="MB" domain={[0, maxRam]} />
|
||||||
<YAxis unit={`${disk_scale_label}/s`} domain={[0, "auto"]} />
|
<Line type="monotone" dataKey="RAM" unit="MB" dot={false} />
|
||||||
<Line type="monotone" dataKey="DISK_READ" unit={`${disk_scale_label}/s`} stroke="red" dot={false} />
|
{toolTip}
|
||||||
<Line type="monotone" dataKey="DISK_WRITE" unit={`${disk_scale_label}/s`} stroke="green" dot={false} />
|
</LineChart>
|
||||||
{toolTip}
|
</ResponsiveContainer>
|
||||||
<Legend />
|
<h2>Network</h2>
|
||||||
</LineChart>
|
<ResponsiveContainer height={200}>
|
||||||
</ResponsiveContainer>
|
<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>
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user