Adds new tile on the admin home page with system data. (#301)

* Adds new tile on the admin home page with system data. Also fixes the active users bug in the pie chart

* Fixes missing parentheses

* Updates user stats cache when signing in

* Reads active number of users from session provider

* Removes unused variable

* Small improvements

* Removes acl properties from system data websocket and performs initial push of data

* fix: remove acl fetch

---------

Co-authored-by: DecDuck <declanahofmeyr@gmail.com>
This commit is contained in:
Paco
2026-01-21 23:58:21 +00:00
committed by GitHub
parent 82cdc1e1aa
commit d8db5b5b85
18 changed files with 243 additions and 23 deletions

View File

@@ -11,7 +11,7 @@
</svg>
<div class="absolute inset-0 bg-zinc-900 rounded-full m-12" />
</div>
<ul class="flex flex-col gap-y-1 justify-center text-left">
<ul class="flex flex-col gap-y-1 my-auto text-left">
<li
v-for="slice in slices"
:key="slice.value"

View File

@@ -92,14 +92,14 @@
v-if="source.fsStats"
:percentage="
getPercentage(
source.fsStats.freeSpace,
source.fsStats.totalSpace - source.fsStats.freeSpace,
source.fsStats.totalSpace,
)
"
:color="
getBarColor(
getPercentage(
source.fsStats.freeSpace,
source.fsStats.totalSpace - source.fsStats.freeSpace,
source.fsStats.totalSpace,
),
)
@@ -148,6 +148,7 @@ import { BackwardIcon, CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import { DropLogo } from "#components";
import { formatBytes } from "~/server/internal/utils/files";
import { getBarColor } from "~/utils/colors";
import { getPercentage } from "~/utils/utils";
const {
sources,
@@ -183,7 +184,4 @@ const optionsMetadata: {
icon: BackwardIcon,
},
};
const getPercentage = (value: number, total: number) =>
((total - value) * 100) / total;
</script>

21
composables/admin-home.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { SerializeObject } from "nitropack";
import type { SystemData } from "~/server/internal/system-data";
const ws = new WebSocketHandler("/api/v1/admin/system-data/ws");
export const useSystemData = () =>
useState<SerializeObject<SystemData>>(
"system-data",
(): SystemData => ({
totalRam: 0,
freeRam: 0,
cpuLoad: 0,
cpuCores: 0,
}),
);
ws.listen((systemDataString) => {
const data = JSON.parse(systemDataString) as SerializeObject<SystemData>;
const systemData = useSystemData();
systemData.value = data;
});

View File

@@ -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",

View File

@@ -105,6 +105,61 @@
<PieChart :data="pieChartData" />
</TileWithLink>
</div>
<div class="col-span-6 row-span-1 lg:col-span-2 lg:row-span-2">
<TileWithLink title="System">
<div class="h-full pb-15 content-center">
<div class="grid grid-cols-1 text-center gap-4">
<h3 class="col-span-1 text-lg font-semibold flex">
<div class="flex-1 text-left">
{{ $t("home.admin.cpuUsage") }}
</div>
<div class="flex-1 text-sm grow text-right self-center">
{{ $t("home.admin.numberCores", systemData.cpuCores) }}
</div>
</h3>
<div class="col-span-1">
<ProgressBar
:color="getBarColor(systemData.cpuLoad)"
:percentage="systemData.cpuLoad"
/>
</div>
<h3 class="col-span-1 text-lg font-semibold my-2 flex">
<div class="flex-none text-left">
{{ $t("home.admin.ramUsage") }}
</div>
<div class="flex-1 text-sm grow text-right self-center">
{{
$t("home.admin.availableRam", {
freeRam: formatBytes(systemData.freeRam),
totalRam: formatBytes(systemData.totalRam),
})
}}
</div>
</h3>
<div class="col-span-1">
<ProgressBar
:color="
getBarColor(
getPercentage(
systemData.totalRam - systemData.freeRam,
systemData.totalRam,
),
)
"
:percentage="
getPercentage(
systemData.totalRam - systemData.freeRam,
systemData.totalRam,
)
"
/>
</div>
</div>
</div>
</TileWithLink>
</div>
<div class="col-span-6">
<TileWithLink
title="Library"
@@ -139,6 +194,8 @@ import { formatBytes } from "~/server/internal/utils/files";
import GamepadIcon from "~/components/Icons/GamepadIcon.vue";
import DropLogo from "~/components/DropLogo.vue";
import { ServerStackIcon, UserGroupIcon } from "@heroicons/vue/24/outline";
import { getPercentage } from "~/utils/utils";
import { getBarColor } from "~/utils/colors";
import type { GameSize } from "~/server/internal/gamesize";
import type { RankItem } from "~/components/RankingList.vue";
@@ -152,6 +209,8 @@ useHead({
const { t } = useI18n();
const systemData = useSystemData();
const {
version,
gameCount,

View File

@@ -0,0 +1,36 @@
import systemManager from "~/server/internal/system-data";
import aclManager from "~/server/internal/acls";
import { logger } from "~/server/internal/logging";
// TODO add web socket sessions for horizontal scaling
// Peer ID to user ID
const socketSessions = new Map<string, string>();
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);
},
});

View File

@@ -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);

View File

@@ -43,6 +43,8 @@ export const userACLDescriptions: ObjectFromList<typeof userACLs> = {
"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<typeof systemACLs> = {
@@ -108,4 +110,7 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"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.",
};

View File

@@ -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:";

View File

@@ -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);
}
}

View File

@@ -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;
},

View File

@@ -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) {

View File

@@ -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

View File

@@ -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) {

View File

@@ -50,4 +50,5 @@ export interface SessionProvider {
removeSession: (token: string) => Promise<boolean>;
cleanupSessions: () => Promise<void>;
findSessions: (options: SessionSearchTerms) => Promise<SessionWithToken[]>;
getNumberActiveSessions: () => Promise<number>;
}

View File

@@ -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<string, { callback: (systemData: SystemData) => 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;

View File

@@ -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<number>("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);
}

7
utils/utils.ts Normal file
View File

@@ -0,0 +1,7 @@
export const getPercentage = (value: number, total: number) => {
const percentage = (value * 100) / total;
if (!isNaN(percentage)) {
return percentage;
}
return 0;
};