From 2e86e827ca6802a97405bcec5cd0ab89dbd6c03e Mon Sep 17 00:00:00 2001 From: Derock Date: Tue, 7 Nov 2023 21:36:19 -0500 Subject: [PATCH] wip: system stats, move to ws --- package.json | 10 ++- pnpm-lock.yaml | 88 +++++++++++++++++++ src/app/home/StatCard.tsx | 81 ++++++++++++----- src/app/home/SystemStatistics.tsx | 66 ++++++++++++++ src/app/home/page.tsx | 10 +-- src/env.mjs | 6 +- .../auth/permissions/ProjectPermissions.ts | 37 -------- src/server/api/root.ts | 2 + src/server/api/routers/system/index.ts | 30 +++++++ src/server/server.ts | 74 ++++++++++++++++ src/server/utils/serverUtils.ts | 39 ++++++++ tsconfig.server.json | 9 ++ 12 files changed, 383 insertions(+), 69 deletions(-) create mode 100644 src/app/home/SystemStatistics.tsx delete mode 100644 src/server/api/auth/permissions/ProjectPermissions.ts create mode 100644 src/server/api/routers/system/index.ts create mode 100644 src/server/server.ts create mode 100644 src/server/utils/serverUtils.ts create mode 100644 tsconfig.server.json diff --git a/package.json b/package.json index 9e3390b..ae79c6a 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf3fcf8..820c780 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/home/StatCard.tsx b/src/app/home/StatCard.tsx index c0dbfe0..9f2f769 100644 --- a/src/app/home/StatCard.tsx +++ b/src/app/home/StatCard.tsx @@ -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(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 ( - + - CPU Usage - + {props.title} + - + {/* chart background */}
- - - + + + + + + + + + - +
-

4.5%

+

{props.value}

- of 8 CPUs + {props.subvalue}

diff --git a/src/app/home/SystemStatistics.tsx b/src/app/home/SystemStatistics.tsx new file mode 100644 index 0000000..02b29c9 --- /dev/null +++ b/src/app/home/SystemStatistics.tsx @@ -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 ( +
+ + + + + + {/* + + */} +
+ ); +} diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx index 02a1d51..19792ee 100644 --- a/src/app/home/page.tsx +++ b/src/app/home/page.tsx @@ -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 (
-
- - - - -
+
); } diff --git a/src/env.mjs b/src/env.mjs index cbd80e6..a15ba44 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -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 diff --git a/src/server/api/auth/permissions/ProjectPermissions.ts b/src/server/api/auth/permissions/ProjectPermissions.ts deleted file mode 100644 index e089228..0000000 --- a/src/server/api/auth/permissions/ProjectPermissions.ts +++ /dev/null @@ -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"); diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 08c3506..b5366f1 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -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 diff --git a/src/server/api/routers/system/index.ts b/src/server/api/routers/system/index.ts new file mode 100644 index 0000000..7225524 --- /dev/null +++ b/src/server/api/routers/system/index.ts @@ -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, + }, + }; + }), +}); diff --git a/src/server/server.ts b/src/server/server.ts new file mode 100644 index 0000000..e387d53 --- /dev/null +++ b/src/server/server.ts @@ -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(); diff --git a/src/server/utils/serverUtils.ts b/src/server/utils/serverUtils.ts new file mode 100644 index 0000000..c55019f --- /dev/null +++ b/src/server/utils/serverUtils.ts @@ -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 | 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, + }); +} diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..511cfe3 --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "dist", + "noEmit": false + }, + "include": ["./src/**/*"] +}