;
+ const systemData = useSystemData();
+ systemData.value = data;
+});
diff --git a/i18n/locales/en_us.json b/i18n/locales/en_us.json
index da77b364..ed7a803f 100644
--- a/i18n/locales/en_us.json
+++ b/i18n/locales/en_us.json
@@ -280,13 +280,17 @@
"activeInactiveUsers": "Active/inactive users",
"activeUsers": "Active users",
"allVersionsCombined": "All versions combined",
+ "availableRam": "({freeRam} / {totalRam})",
"biggestGamesOnServer": "Biggest games on server",
"biggestGamesToDownload": "Biggest games to download",
+ "cpuUsage": "CPU usage",
"games": "Games",
"goToUsers": "Go to users",
"inactiveUsers": "Inactive users",
"latestVersionOnly": "Latest version only",
"librarySources": "Library sources",
+ "numberCores": "({count} cores) | ({count} core) | ({count} cores)",
+ "ramUsage": "RAM usage",
"subheader": "Instance summary",
"title": "Home",
"users": "Users",
diff --git a/pages/admin/index.vue b/pages/admin/index.vue
index 0c85fd14..8d28871c 100644
--- a/pages/admin/index.vue
+++ b/pages/admin/index.vue
@@ -105,6 +105,61 @@
+
+
+
+
+
+
+
+ {{ $t("home.admin.cpuUsage") }}
+
+
+ {{ $t("home.admin.numberCores", systemData.cpuCores) }}
+
+
+
+
+
+ {{ $t("home.admin.ramUsage") }}
+
+
+ {{
+ $t("home.admin.availableRam", {
+ freeRam: formatBytes(systemData.freeRam),
+ totalRam: formatBytes(systemData.totalRam),
+ })
+ }}
+
+
+
+
+
+
+
+
();
+
+export default defineWebSocketHandler({
+ async open(peer) {
+ const h3 = { headers: peer.request?.headers ?? new Headers() };
+ const userId = await aclManager.getUserIdACL(h3, ["system-data:listen"]);
+ if (!userId) {
+ peer.send("unauthenticated");
+ return;
+ }
+
+ socketSessions.set(peer.id, userId);
+
+ systemManager.listen(userId, peer.id, (systemData) => {
+ peer.send(JSON.stringify(systemData));
+ });
+ },
+
+ async close(peer, _details) {
+ const userId = socketSessions.get(peer.id);
+ if (!userId) {
+ logger.info(`skipping websocket close for ${peer.id}`);
+ return;
+ }
+
+ systemManager.unlisten(userId, peer.id);
+ systemManager.unlisten("system", peer.id); // In case we were listening as 'system'
+ socketSessions.delete(peer.id);
+ },
+});
diff --git a/server/api/v1/auth/odic/callback.get.ts b/server/api/v1/auth/odic/callback.get.ts
index 04d7a8e5..b302925e 100644
--- a/server/api/v1/auth/odic/callback.get.ts
+++ b/server/api/v1/auth/odic/callback.get.ts
@@ -1,6 +1,7 @@
import sessionHandler from "~/server/internal/session";
import authManager from "~/server/internal/auth";
import type { Session } from "~/server/internal/session/types";
+import userStatsManager from "~/server/internal/userstats";
defineRouteMeta({
openAPI: {
@@ -61,6 +62,7 @@ export default defineEventHandler(async (h3) => {
`/auth/mfa?redirect=${result.options.redirect ? encodeURIComponent(result.options.redirect) : "/"}`,
);
}
+ await userStatsManager.cacheUserSessions();
if (result.options.redirect) {
return sendRedirect(h3, result.options.redirect);
diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts
index 5e4a7091..c46e5fcc 100644
--- a/server/internal/acls/descriptions.ts
+++ b/server/internal/acls/descriptions.ts
@@ -43,6 +43,8 @@ export const userACLDescriptions: ObjectFromList = {
"emoji:read": "Read built in emojis",
"settings:read": "Read system settings.",
+ "system-data:listen":
+ "Connect to a websocket to receive system data updates.",
};
export const systemACLDescriptions: ObjectFromList = {
@@ -108,4 +110,7 @@ export const systemACLDescriptions: ObjectFromList = {
"depot:new": "Create a new download depot",
"depot:delete": "Remove a download depot",
+
+ "system-data:listen":
+ "Connect to a websocket to receive system data updates.",
};
diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts
index 2aca8f02..80d2b189 100644
--- a/server/internal/acls/index.ts
+++ b/server/internal/acls/index.ts
@@ -37,6 +37,8 @@ export const userACLs = [
"news:read",
"settings:read",
+
+ "system-data:listen",
] as const;
const userACLPrefix = "user:";
@@ -100,6 +102,8 @@ export const systemACLs = [
"maintenance:read",
"settings:update",
+
+ "system-data:listen",
] as const;
const systemACLPrefix = "system:";
diff --git a/server/internal/notifications/index.ts b/server/internal/notifications/index.ts
index 5585087f..db84a96b 100644
--- a/server/internal/notifications/index.ts
+++ b/server/internal/notifications/index.ts
@@ -56,7 +56,7 @@ class NotificationSystem {
},
});
for (const notification of notifications) {
- await listener.callback(notification);
+ listener.callback(notification);
}
}
@@ -69,7 +69,7 @@ class NotificationSystem {
notification.acls.findIndex(
(e) => listener.acls.findIndex((v) => v === e) != -1,
) != -1;
- if (hasSome) await listener.callback(notification);
+ if (hasSome) listener.callback(notification);
}
}
diff --git a/server/internal/session/cache.ts b/server/internal/session/cache.ts
index fd6387f0..1fff5d8d 100644
--- a/server/internal/session/cache.ts
+++ b/server/internal/session/cache.ts
@@ -23,6 +23,11 @@ export default function createCacheSessionProvider() {
const session = await sessions.get(token);
return session ? (session as T) : undefined; // Ensure undefined is returned if session is not found
},
+ async getNumberActiveSessions() {
+ const now = new Date();
+ const allSessions = await sessions.getItems(await sessions.getKeys());
+ return allSessions.filter(({ value }) => value.expiresAt > now).length;
+ },
async updateSession(token, data) {
return (await this.setSession(token, data)) !== undefined;
},
diff --git a/server/internal/session/db.ts b/server/internal/session/db.ts
index aa53fbcb..743875a9 100644
--- a/server/internal/session/db.ts
+++ b/server/internal/session/db.ts
@@ -1,3 +1,5 @@
+import { DateTime } from "luxon";
+
import prisma from "../db/database";
import type { SessionProvider, SessionWithToken } from "./types";
import cacheHandler from "../cache";
@@ -76,6 +78,20 @@ export default function createDBSessionHandler(): SessionProvider {
},
});
},
+ async getNumberActiveSessions() {
+ return (
+ (
+ await prisma.session.groupBy({
+ by: ["userId"],
+ where: {
+ expiresAt: {
+ gt: DateTime.now().toJSDate(),
+ },
+ },
+ })
+ ).length || 0
+ );
+ },
async findSessions(options) {
const search: SessionWhereInput[] = [];
if (options.userId) {
diff --git a/server/internal/session/index.ts b/server/internal/session/index.ts
index a96a0183..a50da598 100644
--- a/server/internal/session/index.ts
+++ b/server/internal/session/index.ts
@@ -10,8 +10,10 @@ import { parse as parseCookies } from "cookie-es";
import type { MinimumRequestObject } from "~/server/h3";
import type { DurationLike } from "luxon";
import { DateTime } from "luxon";
-import createDBSessionHandler from "./db";
import prisma from "../db/database";
+// import createMemorySessionHandler from "./memory";
+import createDBSessionHandler from "./db";
+// import createCacheSessionProvider from "./cache";
/*
This implementation may need work.
@@ -49,7 +51,7 @@ export class SessionHandler {
// Create a new provider
// this.sessionProvider = createCacheSessionProvider();
this.sessionProvider = createDBSessionHandler();
- // this.sessionProvider = createMemorySessionProvider();
+ // this.sessionProvider = createMemorySessionHandler();
}
async signin(
@@ -217,6 +219,10 @@ export class SessionHandler {
return await this.sessionProvider.findSessions(terms);
}
+ async getNumberActiveSessions() {
+ return this.sessionProvider.getNumberActiveSessions();
+ }
+
/**
* Update session info
* @param token session token
diff --git a/server/internal/session/memory.ts b/server/internal/session/memory.ts
index 7f808980..9cd7d46d 100644
--- a/server/internal/session/memory.ts
+++ b/server/internal/session/memory.ts
@@ -22,6 +22,15 @@ export default function createMemorySessionHandler() {
sessions.delete(token);
return true;
},
+ async getNumberActiveSessions() {
+ let activeSessions = 0;
+ for (const [_key, session] of sessions) {
+ if (session.expiresAt.getDate() > Date.now()) {
+ activeSessions += 1;
+ }
+ }
+ return activeSessions;
+ },
async cleanupSessions() {
const now = new Date();
for (const [token, session] of sessions) {
diff --git a/server/internal/session/types.d.ts b/server/internal/session/types.d.ts
index 88a941b6..88558cd6 100644
--- a/server/internal/session/types.d.ts
+++ b/server/internal/session/types.d.ts
@@ -50,4 +50,5 @@ export interface SessionProvider {
removeSession: (token: string) => Promise;
cleanupSessions: () => Promise;
findSessions: (options: SessionSearchTerms) => Promise;
+ getNumberActiveSessions: () => Promise;
}
diff --git a/server/internal/system-data/index.ts b/server/internal/system-data/index.ts
new file mode 100644
index 00000000..3347fead
--- /dev/null
+++ b/server/internal/system-data/index.ts
@@ -0,0 +1,58 @@
+import os from "os";
+
+export type SystemData = {
+ totalRam: number;
+ freeRam: number;
+ cpuLoad: number;
+ cpuCores: number;
+};
+
+class SystemManager {
+ // userId to acl to listenerId
+ private listeners = new Map<
+ string,
+ Map void }>
+ >();
+
+ listen(
+ userId: string,
+ id: string,
+ callback: (systemData: SystemData) => void,
+ ) {
+ if (!this.listeners.has(userId)) this.listeners.set(userId, new Map());
+ // eslint-disable-next-line @typescript-eslint/no-extra-non-null-assertion
+ this.listeners.get(userId)!!.set(id, { callback });
+ this.pushUpdate(userId, id);
+ setInterval(() => this.pushUpdate(userId, id), 3000);
+ }
+
+ unlisten(userId: string, id: string) {
+ this.listeners.get(userId)?.delete(id);
+ }
+
+ private async pushUpdate(userId: string, id: string) {
+ const listener = this.listeners.get(userId)?.get(id);
+ if (!listener) {
+ throw new Error("Failed to catch-up listener: callback does not exist");
+ }
+ listener.callback(this.getSystemData());
+ }
+
+ getSystemData(): SystemData {
+ return {
+ cpuLoad: this.cpuLoad(),
+ totalRam: os.totalmem(),
+ freeRam: os.freemem(),
+ cpuCores: os.cpus().length,
+ };
+ }
+
+ private cpuLoad() {
+ const [oneMinLoad, _fiveMinLoad, _fiftenMinLoad] = os.loadavg();
+ const numberCpus = os.cpus().length;
+ return 100 - ((numberCpus - oneMinLoad) / numberCpus) * 100;
+ }
+}
+
+export const systemManager = new SystemManager();
+export default systemManager;
diff --git a/server/internal/userstats/index.ts b/server/internal/userstats/index.ts
index 799933ac..12345791 100644
--- a/server/internal/userstats/index.ts
+++ b/server/internal/userstats/index.ts
@@ -4,25 +4,14 @@ Handles managing collections
import cacheHandler from "../cache";
import prisma from "../db/database";
-import { DateTime } from "luxon";
+import sessionHandler from "../session";
class UserStatsManager {
// Caches the user's core library
private userStatsCache = cacheHandler.createCache("userStats");
async cacheUserSessions() {
- const activeSessions =
- (
- await prisma.client.groupBy({
- by: ["userId"],
- where: {
- id: { not: "system" },
- lastConnected: {
- gt: DateTime.now().minus({ months: 1 }).toISO(),
- },
- },
- })
- ).length || 0;
+ const activeSessions = await sessionHandler.getNumberActiveSessions();
await this.userStatsCache.set("activeSessions", activeSessions);
}
diff --git a/utils/utils.ts b/utils/utils.ts
new file mode 100644
index 00000000..1ee8e3f6
--- /dev/null
+++ b/utils/utils.ts
@@ -0,0 +1,7 @@
+export const getPercentage = (value: number, total: number) => {
+ const percentage = (value * 100) / total;
+ if (!isNaN(percentage)) {
+ return percentage;
+ }
+ return 0;
+};