wip: system stats, move to ws

This commit is contained in:
Derock 2023-11-07 21:36:19 -05:00
parent 87dd81688b
commit 2e86e827ca
No known key found for this signature in database
12 changed files with 383 additions and 69 deletions

View file

@ -5,7 +5,7 @@
"scripts": {
"build": "next build",
"db:push": "drizzle-kit push:sqlite",
"dev": "next dev",
"dev": "tsx watch src/server/server.ts",
"lint": "next lint",
"start": "next start"
},
@ -24,15 +24,18 @@
"@trpc/server": "^10.43.1",
"argon2": "^0.31.2",
"better-sqlite3": "^9.0.0",
"bufferutil": "^4.0.8",
"bunyan": "^1.8.15",
"bunyan-format": "^0.2.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"dotenv": "^16.3.1",
"drizzle-orm": "^0.28.6",
"ipaddr.js": "^2.1.0",
"next": "^14.0.1",
"next-themes": "^0.2.1",
"node-os-utils": "^1.3.7",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-icons": "^4.11.0",
@ -42,7 +45,9 @@
"tailwind-merge": "^2.0.0",
"tailwindcss-animate": "^1.0.7",
"ts-permissions": "^1.0.0",
"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.33.0",
"ua-parser-js": "^1.0.37",
"ws": "^8.14.2",
"zod": "^3.22.4"
},
"devDependencies": {
@ -51,9 +56,11 @@
"@types/bunyan-format": "^0.2.7",
"@types/eslint": "^8.44.6",
"@types/node": "^20.8.10",
"@types/node-os-utils": "^1.3.4",
"@types/react": "^18.2.35",
"@types/react-dom": "^18.2.14",
"@types/ua-parser-js": "^0.7.38",
"@types/ws": "^8.5.9",
"@typescript-eslint/eslint-plugin": "^6.9.1",
"@typescript-eslint/parser": "^6.9.1",
"autoprefixer": "^10.4.16",
@ -64,6 +71,7 @@
"prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.6",
"tailwindcss": "^3.3.5",
"tsx": "^3.14.0",
"typescript": "^5.2.2"
},
"ct3aMetadata": {

View file

@ -47,6 +47,9 @@ dependencies:
better-sqlite3:
specifier: ^9.0.0
version: 9.0.0
bufferutil:
specifier: ^4.0.8
version: 4.0.8
bunyan:
specifier: ^1.8.15
version: 1.8.15
@ -62,6 +65,9 @@ dependencies:
date-fns:
specifier: ^2.30.0
version: 2.30.0
dotenv:
specifier: ^16.3.1
version: 16.3.1
drizzle-orm:
specifier: ^0.28.6
version: 0.28.6(@types/better-sqlite3@7.6.6)(better-sqlite3@9.0.0)(pg@8.11.3)
@ -74,6 +80,9 @@ dependencies:
next-themes:
specifier: ^0.2.1
version: 0.2.1(next@14.0.1)(react-dom@18.2.0)(react@18.2.0)
node-os-utils:
specifier: ^1.3.7
version: 1.3.7
react:
specifier: 18.2.0
version: 18.2.0
@ -101,9 +110,15 @@ dependencies:
ts-permissions:
specifier: ^1.0.0
version: 1.0.0
uWebSockets.js:
specifier: github:uNetworking/uWebSockets.js#v20.33.0
version: github.com/uNetworking/uWebSockets.js/c10b47c350cc97c299c6b4a07a98abb628704c71
ua-parser-js:
specifier: ^1.0.37
version: 1.0.37
ws:
specifier: ^8.14.2
version: 8.14.2(bufferutil@4.0.8)
zod:
specifier: ^3.22.4
version: 3.22.4
@ -124,6 +139,9 @@ devDependencies:
'@types/node':
specifier: ^20.8.10
version: 20.8.10
'@types/node-os-utils':
specifier: ^1.3.4
version: 1.3.4
'@types/react':
specifier: ^18.2.35
version: 18.2.35
@ -133,6 +151,9 @@ devDependencies:
'@types/ua-parser-js':
specifier: ^0.7.38
version: 0.7.38
'@types/ws':
specifier: ^8.5.9
version: 8.5.9
'@typescript-eslint/eslint-plugin':
specifier: ^6.9.1
version: 6.9.1(@typescript-eslint/parser@6.9.1)(eslint@8.53.0)(typescript@5.2.2)
@ -163,6 +184,9 @@ devDependencies:
tailwindcss:
specifier: ^3.3.5
version: 3.3.5
tsx:
specifier: ^3.14.0
version: 3.14.0
typescript:
specifier: ^5.2.2
version: 5.2.2
@ -1328,6 +1352,10 @@ packages:
resolution: {integrity: sha512-AuHIyzR5Hea7ij0P9q7vx7xu4z0C28ucwjAZC0ja7JhINyCnOw8/DnvAPQQ9TfOlCtZAmCERKQX9+o1mgQhuOQ==}
dev: false
/@types/node-os-utils@1.3.4:
resolution: {integrity: sha512-BCUYrbdoO4FUbx6MB9atLNFnkxdliFaxdiTJMIPPiecXIApc5zf4NIqV5G1jWv/ReZvtYyHLs40RkBjHX+vykA==}
dev: true
/@types/node@17.0.45:
resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==}
dev: false
@ -1382,6 +1410,12 @@ packages:
'@types/webidl-conversions': 7.0.2
dev: false
/@types/ws@8.5.9:
resolution: {integrity: sha512-jbdrY0a8lxfdTp/+r7Z4CkycbOFN8WX+IOchLJr3juT/xzbJ8URyTVSJ/hvNdadTgM1mnedb47n+Y31GsFnQlg==}
dependencies:
'@types/node': 20.8.10
dev: true
/@typescript-eslint/eslint-plugin@6.9.1(@typescript-eslint/parser@6.9.1)(eslint@8.53.0)(typescript@5.2.2):
resolution: {integrity: sha512-w0tiiRc9I4S5XSXXrMHOWgHgxbrBn1Ro+PmiYhSg2ZVdxrAJtQgzU5o2m1BfP6UOn7Vxcc6152vFjQfmZR4xEg==}
engines: {node: ^16.0.0 || >=18.0.0}
@ -1919,6 +1953,14 @@ packages:
ieee754: 1.2.1
dev: false
/bufferutil@4.0.8:
resolution: {integrity: sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==}
engines: {node: '>=6.14.2'}
requiresBuild: true
dependencies:
node-gyp-build: 4.6.1
dev: false
/bunyan-format@0.2.1:
resolution: {integrity: sha512-xQs2LwWskjQdv7bVkMNwvMi7HnvDQoX4587H90nDGQGPPwHrmxsihBOIYHMVwjLMMOokITKPyFcbFneblvMEjQ==}
dependencies:
@ -2442,6 +2484,11 @@ packages:
engines: {node: '>=12'}
dev: false
/dotenv@16.3.1:
resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==}
engines: {node: '>=12'}
dev: false
/dreamopt@0.8.0:
resolution: {integrity: sha512-vyJTp8+mC+G+5dfgsY+r3ckxlz+QMX40VjPQsZc5gxVAxLmi64TBoVkP54A/pRAXMXsbu2GMMBrZPxNv23waMg==}
engines: {node: '>=0.4.0'}
@ -4519,6 +4566,15 @@ packages:
whatwg-url: 5.0.0
dev: false
/node-gyp-build@4.6.1:
resolution: {integrity: sha512-24vnklJmyRS8ViBNI8KbtK/r/DmXQMRiOMXTNz2nrTnAYUwjmEEbnnpB/+kt+yWRv73bPsSPRFddrcIbAxSiMQ==}
hasBin: true
dev: false
/node-os-utils@1.3.7:
resolution: {integrity: sha512-fvnX9tZbR7WfCG5BAy3yO/nCLyjVWD6MghEq0z5FDfN+ZXpLWNITBdbifxQkQ25ebr16G0N7eRWJisOcMEHG3Q==}
dev: false
/node-releases@2.0.13:
resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==}
dev: true
@ -6031,6 +6087,17 @@ packages:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
dev: false
/tsx@3.14.0:
resolution: {integrity: sha512-xHtFaKtHxM9LOklMmJdI3BEnQq/D5F73Of2E1GDrITi9sgoVkvIsrQUTY1G8FlmGtA+awCI4EBlTRRYxkL2sRg==}
hasBin: true
dependencies:
esbuild: 0.18.20
get-tsconfig: 4.7.2
source-map-support: 0.5.21
optionalDependencies:
fsevents: 2.3.3
dev: true
/tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
dependencies:
@ -6320,6 +6387,21 @@ packages:
/wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
/ws@8.14.2(bufferutil@4.0.8):
resolution: {integrity: sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
dependencies:
bufferutil: 4.0.8
dev: false
/xtend@2.1.2:
resolution: {integrity: sha512-vMNKzr2rHP9Dp/e1NQFnLQlwlhp9L/LfvnsVdHxN1f+uggyVI3i08uD14GPvCToPkdsRfyPqIyYGmIk58V98ZQ==}
engines: {node: '>=0.4'}
@ -6354,3 +6436,9 @@ packages:
/zod@3.22.4:
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
github.com/uNetworking/uWebSockets.js/c10b47c350cc97c299c6b4a07a98abb628704c71:
resolution: {tarball: https://codeload.github.com/uNetworking/uWebSockets.js/tar.gz/c10b47c350cc97c299c6b4a07a98abb628704c71}
name: uWebSockets.js
version: 20.33.0
dev: false

View file

@ -2,48 +2,81 @@
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { RiPulseFill } from "react-icons/ri";
import { LineChart, Line, ResponsiveContainer } from "recharts";
import { ResponsiveContainer, AreaChart, Area } from "recharts";
import styles from "./StatCard.module.css";
const TEST_DATA = [
{ cpu: 0.05 },
{ cpu: 0.1 },
{ cpu: 0.08 },
{ cpu: 0.09 },
{ cpu: 0.2 },
{ cpu: 0.1 },
{ cpu: 0.12 },
{ cpu: 0.3 },
];
export function StatCard<T extends { [key: string]: number }>(props: {
title: string;
value: string;
subvalue: string;
icon: React.FC<{ className: string }>;
data: T[];
dataKey: keyof T & string;
}) {
const rechartsColorId = `color${props.dataKey}`;
const Icon = props.icon;
export function StatCard() {
return (
<Card>
<Card className="relative overflow-clip">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>CPU Usage</CardTitle>
<RiPulseFill className="text-2xl text-muted-foreground" />
<CardTitle>{props.title}</CardTitle>
<Icon className="text-2xl text-muted-foreground" />
</CardHeader>
<CardContent className="relative">
<CardContent>
{/* chart background */}
<div className="absolute inset-0 z-0 h-full w-full">
<ResponsiveContainer width={"100%"} height={"100%"}>
<LineChart data={TEST_DATA}>
<Line
<ResponsiveContainer
width={"100%"}
height={"50%"}
className="absolute bottom-0 left-0"
>
<AreaChart
data={props.data}
margin={{
top: 0,
right: 0,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient
id={rechartsColorId}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="hsl(var(--primary))"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="hsl(var(--primary))"
stopOpacity={0.4}
/>
</linearGradient>
</defs>
<Area
type="monotone"
dataKey={"cpu"}
stroke="hsl(var(--border))"
dataKey={props.dataKey}
stroke="hsl(var(--primary))"
fill={`url(#${rechartsColorId})`}
strokeWidth={2}
dot={false}
/>
</LineChart>
</AreaChart>
</ResponsiveContainer>
</div>
<div className={`relative z-10 ${styles["stat-card"]}`}>
<p className="stroke stroke-card text-2xl font-bold">4.5%</p>
<p className="stroke stroke-card text-2xl font-bold">{props.value}</p>
<p className="stroke stroke-card text-sm text-muted-foreground">
of 8 CPUs
{props.subvalue}
</p>
</div>
</CardContent>

View file

@ -0,0 +1,66 @@
"use client";
import { api } from "~/trpc/react";
import { StatCard } from "./StatCard";
import { RouterOutputs } from "~/trpc/shared";
import { RiPulseFill } from "react-icons/ri";
const TEST_DATA = [
{ cpu: 0.05 },
{ cpu: 0.1 },
{ cpu: 0.08 },
{ cpu: 0.09 },
{ cpu: 0.2 },
{ cpu: 0.1 },
{ cpu: 0.12 },
{ cpu: 0.3 },
];
export function SystemStatistics(props: {
initialData: RouterOutputs["system"]["current"];
}) {
const { data } = api.system.current.useQuery(undefined, {
initialData: props.initialData,
refetchInterval: 5_000,
});
return (
<div className="m-8 grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="CPU Usage"
value={`${data.cpu.usage ?? 0}%`}
subvalue={`of ${data.cpu.cores} CPUs`}
icon={RiPulseFill}
data={TEST_DATA}
dataKey="cpu"
/>
<StatCard
title="Memory Usage"
value={`${((data.memory.used / data.memory.total) * 100).toFixed(2)}%`}
subvalue={`${data.memory.used.toFixed(2)} / ${data.memory.total.toFixed(
2,
)} GB`}
icon={RiPulseFill}
data={TEST_DATA}
dataKey="cpu"
/>
<StatCard
title="Disk Usage"
value={`${((data.storage.used / data.storage.total) * 100).toFixed(
2,
)}%`}
subvalue={`${data.storage.used.toFixed(
2,
)} / ${data.storage.total.toFixed(2)} GB`}
icon={RiPulseFill}
data={TEST_DATA}
dataKey="cpu"
/>
{/* <StatCard />
<StatCard />
<StatCard /> */}
</div>
);
}

View file

@ -1,17 +1,15 @@
import { api } from "~/trpc/server";
import Test from "./RSC";
import { StatCard } from "./StatCard";
import { SystemStatistics } from "./SystemStatistics";
export default async function DashboardHome() {
const initialStats = await api.system.current.query();
return (
<div className="mx-auto max-w-[1500px]">
<Test />
<div className="m-8 grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
<StatCard />
<StatCard />
<StatCard />
<StatCard />
</div>
<SystemStatistics initialData={initialStats} />
</div>
);
}

View file

@ -14,7 +14,9 @@ export const env = createEnv({
.default("development"),
SQLITE_UUIDV7_EXT_PATH: z.string().optional(),
SESSION_SECRET: z.string(),
SESSION_SECRET: z.string().min(8),
HOSTNAME: z.string().default("localhost"),
PORT: z.number().default(3000),
},
/**
@ -35,6 +37,8 @@ export const env = createEnv({
NODE_ENV: process.env.NODE_ENV,
SQLITE_UUIDV7_EXT_PATH: process.env.SQLITE_UUIDV7_EXT_PATH,
SESSION_SECRET: process.env.SESSION_SECRET,
HOSTNAME: process.env.HOSTNAME,
PORT: process.env.PORT,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially

View file

@ -1,37 +0,0 @@
import { createPermissionsClass } from "ts-permissions";
enum ProjectPermissionsEnum {
/**
* Can do anything **including** deleting the project.
*/
Owner,
/**
* Can do anything **except** delete the project.
*/
Administrator,
/**
* Can add/remove members and grant permissions (lower than their own) to members, with an exception to Owners.
*/
ManageMembers,
/**
* Can create and view services.
*/
CreateService,
/**
* Can only view services.
*/
ViewServices,
/**
* Can edit and delete services.
*/
ManageServices,
}
export const ProjectPermissions = createPermissionsClass<
typeof ProjectPermissionsEnum
>(ProjectPermissionsEnum, "ProjectPermissions");

View file

@ -1,6 +1,7 @@
import { createTRPCRouter } from "~/server/api/trpc";
import { setupProcedure } from "./routers/setup";
import { authRouter } from "./routers/auth";
import { systemRouter } from "./routers/system";
/**
* This is the primary router for your server.
@ -10,6 +11,7 @@ import { authRouter } from "./routers/auth";
export const appRouter = createTRPCRouter({
setup: setupProcedure,
auth: authRouter,
system: systemRouter,
});
// export type definition of API

View file

@ -0,0 +1,30 @@
import { authenticatedProcedure, createTRPCRouter } from "../../trpc";
import os from "os";
import osu from "node-os-utils";
export const systemRouter = createTRPCRouter({
current: authenticatedProcedure.query(async ({ ctx }) => {
const [cpuUsage, storage, memory] = await Promise.all([
osu.cpu.usage(),
osu.drive.info("/"),
osu.mem.info(),
]);
return {
cpu: {
usage: cpuUsage,
cores: os.cpus().length,
},
storage: {
used: parseInt(storage.usedGb),
total: parseInt(storage.totalGb),
},
memory: {
used: memory.usedMemMb / 1024,
total: memory.totalMemMb / 1024,
},
};
}),
});

74
src/server/server.ts Normal file
View file

@ -0,0 +1,74 @@
import "dotenv/config";
import next from "next";
import { env } from "~/env.mjs";
import { createServer } from "http";
import logger from "./utils/logger";
import ws from "ws";
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { appRouter } from "./api/root";
import { createTRPCContext } from "./api/trpc";
import { incomingRequestToNextRequest } from "./utils/serverUtils";
async function startApp() {
// initialize the next app
const app = next({
dev: env.NODE_ENV !== "production",
hostname: env.HOSTNAME,
port: env.PORT,
});
await app.prepare();
// get the handles
const getHandler = app.getRequestHandler();
const upgradeHandler = app.getUpgradeHandler();
// create the http server
const server = createServer((req, res) => {
try {
// handle the request
getHandler(req, res);
} catch (error) {
logger.error(error);
res.statusCode = 500;
res.end("Internal Server Error");
}
});
// create the websocket server
const wss = new ws.Server({ noServer: true });
const trpcHandler = applyWSSHandler({
wss,
router: appRouter,
createContext: ({ req }) =>
createTRPCContext({
req: incomingRequestToNextRequest(req),
resHeaders: new Headers(),
}),
});
process.on("SIGTERM", () => {
trpcHandler.broadcastReconnectNotification();
server.close(() => {
process.exit(0);
});
});
// handle the upgrade
server.on("upgrade", (req, socket, head) => {
// send trpc requests to the trpc server
if (req.url?.startsWith("/api/trpc")) {
wss.handleUpgrade(req, socket, head, () => {});
} else {
upgradeHandler(req, socket, head);
}
});
// start the server
server.listen(env.PORT, () => {
logger.info(`Server listening on port ${env.PORT}`);
});
}
startApp();

View file

@ -0,0 +1,39 @@
import assert from "assert";
import { IncomingMessage } from "http";
import { NextRequest } from "next/server";
/**
* Turns an node:http IncomingMessage into a next.js request
* @param req The incoming request
*/
export function incomingRequestToNextRequest(req: IncomingMessage) {
// copy headers to a new Headers object
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
headers.set(key, value as string);
}
// fix the body
let body: ReadableStream<Uint8Array> | undefined = undefined;
if (req.method === "POST") {
body = new ReadableStream({
start(controller) {
req.on("data", (chunk) => {
controller.enqueue(chunk);
});
req.on("end", () => {
controller.close();
});
},
});
}
// create the web request
assert(req.url, "req.url is undefined");
return new NextRequest(req.url, {
method: req.method,
body,
headers,
});
}

9
tsconfig.server.json Normal file
View file

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "dist",
"noEmit": false
},
"include": ["./src/**/*"]
}