feat: stat graphs

This commit is contained in:
Derock 2023-11-12 09:49:10 -05:00
parent d5caddcb22
commit 3fcdeeb22e
No known key found for this signature in database
12 changed files with 146 additions and 149 deletions

1
.gitignore vendored
View file

@ -10,6 +10,7 @@
# database
/data/
/logs/
# next.js
/.next/

View file

@ -1,95 +0,0 @@
2023-11-12T01:48:57.462Z info: Not running database migrations, use drizzle-kit push to migrate
2023-11-12T01:48:57.463Z info: 🚀 Hostforge v1.0.0 ready!
2023-11-12T01:48:57.469Z info: Server listening on port 3000
2023-11-12T01:49:11.893Z warn: SIGTERM received, shutting down...
2023-11-12T01:49:17.625Z info: Not running database migrations, use drizzle-kit push to migrate
2023-11-12T01:49:17.627Z info: 🚀 Hostforge v1.0.0 ready!
2023-11-12T01:49:17.635Z info: Server listening on port 3000
2023-11-12T01:50:31.777Z warn: SIGTERM received, shutting down...
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge v1.0.0 ready!"}
{"level":"info","message":"Server listening on port 3000"}
{"level":"warn","message":"SIGTERM received, shutting down..."}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge v1.0.0 ready!"}
{"level":"info","message":"Server listening on port 3000"}
{"level":"warn","message":"SIGTERM received, shutting down..."}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge v1.0.0 ready!"}
{"level":"info","message":"Server listening on port 3000"}
{"level":"warn","message":"SIGTERM received, shutting down..."}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge v1.0.0 ready!"}
{"level":"info","message":"Server listening on port 3000"}
{"level":"warn","message":"SIGTERM received, shutting down..."}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge v1.0.0 ready!"}
{"level":"info","message":"Server listening on port 3000"}
{"level":"warn","message":"SIGTERM received, shutting down..."}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge v1.0.0 ready!"}
{"level":"info","message":"Server listening on port 3000"}
{"level":"warn","message":"SIGTERM received, shutting down..."}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge v1.0.0 ready!"}
{"level":"info","message":"Server listening on port 3000"}
{"level":"warn","message":"SIGTERM received, shutting down..."}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge v1.0.0 ready!"}
{"level":"info","message":"Server listening on port 3000"}
{"level":"warn","message":"SIGTERM received, shutting down..."}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge v1.0.0 ready!"}
{"level":"info","message":"Server listening on port 3000"}
{"level":"warn","message":"SIGTERM received, shutting down..."}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge v1.0.0 ready!"}
{"level":"info","message":"Server listening on port 3000"}
{"level":"warn","message":"SIGTERM received, shutting down..."}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge v1.0.0 ready!"}
{"level":"info","message":"Server listening on port 3000"}
{"level":"warn","message":"SIGTERM received, shutting down..."}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge v1.0.0 ready!"}
{"level":"info","message":"Server listening on port 3000"}
{"level":"warn","message":"SIGTERM received, shutting down..."}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge v1.0.0 ready!"}
{"level":"info","message":"Server listening on port 3000"}
{"level":"warn","message":"SIGTERM received, shutting down..."}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge"}
{"level":"info","message":"Server listening on localhost:3000"}
{"level":"info","message":"Version: 0.1.0"}
{"level":"info","message":"Environment: development"}
{"level":"info","message":"Build commit: unknown"}
{"level":"warn","message":"SIGTERM received, shutting down..."}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge"}
{"level":"info","message":" │ Server listening on localhost:3000"}
{"level":"info","message":" │ Version: 0.1.0"}
{"level":"info","message":" │ Environment: development"}
{"level":"info","message":" ╰ Build commit: unknown"}
{"level":"warn","message":"SIGTERM received, shutting down..."}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge"}
{"level":"info","message":"│ Server listening on localhost:3000"}
{"level":"info","message":"│ Version: 0.1.0"}
{"level":"info","message":"│ Environment: development"}
{"level":"info","message":"╰ Build commit: unknown"}
{"level":"warn","message":"SIGTERM received, shutting down..."}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge"}
{"level":"info","message":"│ Server listening on localhost:3000"}
{"level":"info","message":"│ Version: 0.1.0"}
{"level":"info","message":"│ Environment: development"}
{"level":"info","message":"╰ Build commit: unknown"}
{"level":"info","message":"Not running database migrations, use drizzle-kit push to migrate","module":"database"}
{"level":"info","message":"🚀 Hostforge"}
{"level":"info","message":"│ Server listening on 0.0.0.0:3000"}
{"level":"info","message":"│ Version: 0.1.0"}
{"level":"info","message":"│ Environment: development"}
{"level":"info","message":"╰ Build commit: unknown"}

View file

View file

@ -5,7 +5,7 @@ import { ResponsiveContainer, AreaChart, Area } from "recharts";
import styles from "./StatCard.module.css";
import { AnimatedNumber } from "~/components/AnimatedPercent";
export function StatCard<T extends Record<string, number>>(props: {
export function StatCard<T extends Record<string, any>>(props: {
title: string;
icon: React.FC<{ className: string }>;
@ -35,7 +35,7 @@ export function StatCard<T extends Record<string, number>>(props: {
<div className="absolute inset-0 z-0 h-full w-full">
<ResponsiveContainer
width={"100%"}
height={"50%"}
height={"40%"}
className="absolute bottom-0 left-0"
>
<AreaChart

View file

@ -9,22 +9,15 @@ import {
FaHardDrive,
FaEthernet,
} from "react-icons/fa6";
import { useState } from "react";
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 },
];
import { useMemo, useState } from "react";
type StatData = RouterOutputs["system"]["currentStats"];
type HistoricalStatData = RouterOutputs["system"]["history"];
export function SystemStatistics(props: { initialData: StatData }) {
export function SystemStatistics(props: {
initialData: StatData;
historicalData: HistoricalStatData;
}) {
const [data, setData] = useState<StatData>(props.initialData);
api.system.liveStats.useSubscription(undefined, {
@ -33,6 +26,15 @@ export function SystemStatistics(props: { initialData: StatData }) {
},
});
const historicalData = useMemo(
() =>
props.historicalData.map((data) => ({
...data,
network: data.networkTx + data.networkRx,
})),
[props.historicalData],
);
return (
<div className="m-8 grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
@ -41,8 +43,8 @@ export function SystemStatistics(props: { initialData: StatData }) {
unit="%"
subvalue={`of ${data.cpu.cores} CPUs`}
icon={FaMicrochip}
data={TEST_DATA}
dataKey="cpu"
data={historicalData}
dataKey="cpuUsage"
/>
<StatCard
@ -53,8 +55,8 @@ export function SystemStatistics(props: { initialData: StatData }) {
2,
)} GB`}
icon={FaMemory}
data={TEST_DATA}
dataKey="cpu"
data={historicalData}
dataKey="memoryUsage"
/>
<StatCard
@ -65,8 +67,8 @@ export function SystemStatistics(props: { initialData: StatData }) {
2,
)} / ${data.storage.total.toFixed(2)} GB`}
icon={FaHardDrive}
data={TEST_DATA}
dataKey="cpu"
data={historicalData}
dataKey="diskUsage"
/>
<StatCard
@ -81,8 +83,8 @@ export function SystemStatistics(props: { initialData: StatData }) {
secondarySubvalue="RX / Mbps"
// misc
icon={FaEthernet}
data={TEST_DATA}
dataKey="cpu"
data={historicalData}
dataKey="network"
/>
</div>
);

View file

@ -4,12 +4,18 @@ import { StatCard } from "./StatCard";
import { SystemStatistics } from "./SystemStatistics";
export default async function DashboardHome() {
const initialStats = await api.system.currentStats.query();
const [initialStats, historicalData] = await Promise.all([
api.system.currentStats.query(),
api.system.history.query(),
]);
return (
<div className="mx-auto max-w-[1500px]">
<Test />
<SystemStatistics initialData={initialStats} />
<SystemStatistics
initialData={initialStats}
historicalData={historicalData}
/>
</div>
);
}

View file

@ -17,4 +17,10 @@ export const systemRouter = createTRPCRouter({
};
});
}),
history: authenticatedProcedure.query(async ({ ctx }) => {
return await stats.getStatsInRange(
new Date(Date.now() - 1000 * 60 * 60 * 24),
);
}),
});

View file

@ -2,21 +2,30 @@ import { drizzle } from "drizzle-orm/better-sqlite3";
import SQLite3 from "better-sqlite3";
import { join } from "path";
import { env } from "~/env";
import logger from "../utils/logger";
const sqlite = new SQLite3(env.DATABASE_PATH);
const globalForDB = globalThis as unknown as {
db: ReturnType<typeof createDatabaseInstance>;
};
// enable WAL mode
sqlite.pragma("journal_mode = WAL");
function createDatabaseInstance() {
logger.child({ module: "database" }).debug("Creating database client.");
const sqlite = new SQLite3(env.DATABASE_PATH);
// load uuidv7 extension
// built from https://github.com/craigpastro/sqlite-uuidv7
sqlite.loadExtension(
env.SQLITE_UUIDV7_EXT_PATH ??
join(
// cannot use __dirname since this file will change locations when compiled
process.cwd(),
"./exts/sqlite-uuidv7",
),
);
// enable WAL mode
sqlite.pragma("journal_mode = WAL");
export const db = drizzle(sqlite);
// load uuidv7 extension
// built from https://github.com/craigpastro/sqlite-uuidv7
sqlite.loadExtension(
env.SQLITE_UUIDV7_EXT_PATH ??
join(
// cannot use __dirname since this file will change locations when compiled
process.cwd(),
"./exts/sqlite-uuidv7",
),
);
return drizzle(sqlite);
}
export const db = (globalForDB.db ??= createDatabaseInstance());

View file

@ -5,6 +5,7 @@ import {
integer,
sqliteTable,
index,
real,
} from "drizzle-orm/sqlite-core";
// util
@ -75,3 +76,20 @@ export const MFARequestSessionRelations = relations(
}),
}),
);
/**
* System Statistics
* Historical data about the system's usage
*/
export const systemStats = sqliteTable("system_stats", {
timestamp: integer("id").primaryKey().default(now),
// percent as decimal * 10_000 to keep 2 decimal places
cpuUsage: integer("cpu_usage"),
// everything else is in megabytes
memoryUsage: integer("memory_usage").notNull(),
diskUsage: integer("disk_usage").notNull(),
networkTx: integer("network_tx").notNull(),
networkRx: integer("network_rx").notNull(),
});

View file

@ -3,6 +3,9 @@ import TypedEmitter from "typed-emitter";
import osu from "node-os-utils";
import os from "os";
import baseLogger from "../../utils/logger";
import { db } from "~/server/db";
import { systemStats } from "~/server/db/schema";
import { between, lte } from "drizzle-orm";
export type BasicServerStats = {
collectedAt: Date;
@ -86,7 +89,8 @@ type StatEvents = {
* Manages the stats for the current server.
*/
export class StatManager {
private logger = baseLogger.child({ module: "StatManager" });
static readonly DB_MAX_STORE_TIME = /* 1 week */ 7 * 24 * 60 * 60 * 1000;
private logger = baseLogger.child({ module: "stats" });
/**
* The current stats for the server.
@ -126,16 +130,7 @@ export class StatManager {
private liveInterval: NodeJS.Timeout | null = null;
constructor() {
this.update();
// collect stats every hour
setInterval(
async () => {
await this.update();
await this.updateDatabase();
},
60 * 60 * 1000,
);
this.update().then(() => this.updateDatabase());
// whenever a new listener is added, start the live interval
this.events.on("newListener", (event) => {
@ -157,6 +152,18 @@ export class StatManager {
});
}
start() {
this.logger.info("Starting hourly stat collection.");
// collect stats every hour
setInterval(
async () => {
await this.update();
await this.updateDatabase();
},
60 * 60 * 1000,
);
}
/**
* Gets the current stats for the server.
*/
@ -219,7 +226,46 @@ export class StatManager {
/**
* Updates the database with the current stats.
*/
async updateDatabase() {}
async updateDatabase() {
const startTime = Date.now();
await db
.insert(systemStats)
.values({
timestamp: this.currentStats.collectedAt.getTime(),
cpuUsage: this.currentStats.cpu.usage,
diskUsage: this.currentStats.storage.used * 1024,
memoryUsage: this.currentStats.memory.used * 1024,
networkTx: this.currentStats.network.tx / 1024 / 1024,
networkRx: this.currentStats.network.rx / 1024 / 1024,
})
.execute();
this.logger.debug(
`Updated database with new stats. Took ${Date.now() - startTime}ms.`,
);
// delete old stats
const count = await db
.delete(systemStats)
.where(
lte(systemStats.timestamp, Date.now() - StatManager.DB_MAX_STORE_TIME),
)
.execute();
this.logger.debug(`Deleted ${count.changes} old stats from the database.`);
}
/**
* Fetches stats from the database in the given time range.
*/
async getStatsInRange(start: Date, end = Date.now()) {
return db
.select()
.from(systemStats)
.where(between(systemStats.timestamp, start.getTime(), end));
}
}
export const stats = new StatManager();
export const stats = (((globalThis as any).statsManager as
| StatManager
| undefined) ??= new StatManager());

View file

@ -13,6 +13,7 @@ import { db } from "./db";
import { mkdir, stat } from "fs/promises";
import path from "path";
import { version } from "../../package.json";
import { stats } from "./modules/stats";
// check if database folder exists
try {
@ -34,14 +35,15 @@ if (env.NODE_ENV === "production") {
.info("Not running database migrations, use drizzle-kit push to migrate");
}
// start statistics
stats.start();
// initialize the next app
const app = next({
dev: env.NODE_ENV !== "production",
hostname: env.HOSTNAME,
port: env.PORT,
// dir: path.join(__dirname, "../.."),
customServer: true,
isNodeDebugging: true,
});
await app.prepare();

View file

@ -19,6 +19,8 @@ const logger = createLogger({
)} ${message}`;
}),
),
level: "debug",
}),
new transports.File({ filename: "logs/error.log", level: "error" }),
new transports.File({ filename: "logs/combined.log" }),