Admin home page #128 (#259)

* First iteration on the new PieChart component

* #128 Adds new admin home page

* Fixes code after merging conflicts

* Removes empty file

* Uses real data for admin home page, and improves style

* Reverts debugging code

* Defines missing variable

* Caches user stats data for admin home page

* Typo

* Styles improvements

* Invalidates cache on signup/signin

* Implements top 5 biggest games

* Improves styling

* Improves style

* Using generateManifest to get the proper size

* Reading data from cache

* Removes unnecessary import

* Improves caching mechanism for game sizes

* Removes lint errors

* Replaces piechart tooltip with colors in legend

* Fixes caching

* Fixes caching and slight improvement on pie chart colours

* Fixes a few bugs related to caching

* Fixes bug where app signin didn't refresh cache

* feat: style improvements

* fix: lint

---------

Co-authored-by: DecDuck <declanahofmeyr@gmail.com>
This commit is contained in:
Paco
2025-11-07 22:14:45 +00:00
committed by GitHub
parent 289034d0c8
commit dfa30c8a65
40 changed files with 1352 additions and 150 deletions

View File

@ -1,5 +1,5 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:delete"]);
@ -7,11 +7,7 @@ export default defineEventHandler(async (h3) => {
const gameId = getRouterParam(h3, "id")!;
await prisma.game.delete({
where: {
id: gameId,
},
});
libraryManager.deleteGame(gameId);
return {};
});

View File

@ -1,3 +1,4 @@
import type { GameVersion } from "~/prisma/client/client";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
@ -28,10 +29,22 @@ export default defineEventHandler(async (h3) => {
if (!game || !game.libraryId)
throw createError({ statusCode: 404, statusMessage: "Game ID not found" });
const getGameVersionSize = async (version: GameVersion) => {
const size = await libraryManager.getGameVersionSize(
gameId,
version.versionName,
);
return { ...version, size };
};
const gameWithVersionSize = {
...game,
versions: await Promise.all(game.versions.map(getGameVersionSize)),
};
const unimportedVersions = await libraryManager.fetchUnimportedGameVersions(
game.libraryId,
game.libraryPath,
);
return { game, unimportedVersions };
return { game: gameWithVersionSize, unimportedVersions };
});

View File

@ -1,7 +1,7 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
const DeleteVersion = type({
id: "string",
@ -20,15 +20,7 @@ export default defineEventHandler<{ body: typeof DeleteVersion }>(
const gameId = body.id.toString();
const version = body.versionName.toString();
await prisma.gameVersion.delete({
where: {
gameId_versionName: {
gameId: gameId,
versionName: version,
},
},
});
await libraryManager.deleteGameVersion(gameId, version);
return {};
},
);

View File

@ -0,0 +1,27 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import { systemConfig } from "~/server/internal/config/sys-conf";
import libraryManager from "~/server/internal/library";
import userStatsManager from "~/server/internal/userstats";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const sources = await libraryManager.fetchLibraries();
const userStats = await userStatsManager.getUserStats();
const biggestGamesCombined =
await libraryManager.getBiggestGamesCombinedVersions(5);
const biggestGamesLatest =
await libraryManager.getBiggestGamesLatestVersions(5);
return {
gameCount: await prisma.game.count(),
version: systemConfig.getDropVersion(),
userStats,
sources,
biggestGamesLatest,
biggestGamesCombined,
};
});

View File

@ -2,7 +2,10 @@ import type { LibraryModel } from "~/prisma/client/models";
import aclManager from "~/server/internal/acls";
import libraryManager from "~/server/internal/library";
export type WorkingLibrarySource = LibraryModel & { working: boolean };
export type WorkingLibrarySource = LibraryModel & {
working: boolean;
fsStats?: { freeSpace: number; totalSpace: number } | undefined;
};
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, [

View File

@ -3,8 +3,8 @@ import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
import { libraryConstructors } from "~/server/plugins/05.library-init";
import type { WorkingLibrarySource } from "./index.get";
const UpdateLibrarySource = type({
id: "string",
@ -49,8 +49,8 @@ export default defineEventHandler<{ body: typeof UpdateLibrarySource.infer }>(
},
});
await libraryManager.removeLibrary(source.id);
await libraryManager.addLibrary(newLibrary);
libraryManager.removeLibrary(source.id);
libraryManager.addLibrary(newLibrary);
const workingSource: WorkingLibrarySource = {
...updatedSource,

View File

@ -6,7 +6,7 @@ import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
import { libraryConstructors } from "~/server/plugins/05.library-init";
import type { WorkingLibrarySource } from "./index.get";
import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
const CreateLibrarySource = type({
name: "string",
@ -52,11 +52,12 @@ export default defineEventHandler<{ body: typeof CreateLibrarySource.infer }>(
},
});
await libraryManager.addLibrary(library);
libraryManager.addLibrary(library);
const workingSource: WorkingLibrarySource = {
...source,
working: true,
fsStats: library.fsStats(),
};
return workingSource;

View File

@ -1,6 +1,7 @@
import { defineEventHandler, createError } from "h3";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import userStatsManager from "~/server/internal/userstats";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["user:delete"]);
@ -27,5 +28,6 @@ export default defineEventHandler(async (h3) => {
throw createError({ statusCode: 404, statusMessage: "User not found." });
await prisma.user.delete({ where: { id: userId } });
await userStatsManager.deleteUser();
return { success: true };
});

View File

@ -6,6 +6,7 @@ import objectHandler from "~/server/internal/objects";
import { type } from "arktype";
import { randomUUID } from "node:crypto";
import { throwingArktype } from "~/server/arktype";
import userStatsManager from "~/server/internal/userstats";
export const SharedRegisterValidator = type({
username: "string >= 5",
@ -86,5 +87,6 @@ export default defineEventHandler<{
prisma.invitation.delete({ where: { id: user.invitation } }),
]);
await userStatsManager.addUser();
return linkMec.user;
});

View File

@ -1,5 +1,6 @@
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
export default defineClientEventHandler(async (h3) => {
const query = getQuery(h3);
@ -26,5 +27,8 @@ export default defineClientEventHandler(async (h3) => {
statusMessage: "Game version not found",
});
return gameVersion;
return {
...gameVersion,
size: libraryManager.getGameVersionSize(id, version),
};
});

View File

@ -1,5 +1,6 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
@ -51,5 +52,7 @@ export default defineEventHandler(async (h3) => {
},
});
return { game, rating };
const size = await libraryManager.getGameVersionSize(game.id);
return { game, rating, size };
});

View File

@ -8,6 +8,7 @@ import type {
} from "./capabilities";
import capabilityManager from "./capabilities";
import type { PeerImpl } from "../tasks";
import userStatsManager from "~/server/internal/userstats";
export enum AuthMode {
Callback = "callback",
@ -136,7 +137,7 @@ export class ClientHandler {
statusCode: 400,
statusMessage: "Client has not connected yet. Please try again later.",
});
await client.peer.send(
client.peer.send(
JSON.stringify({ type: "token", value: `${clientId}/${token}` }),
);
}
@ -166,6 +167,7 @@ export class ClientHandler {
lastConnected: new Date(),
},
});
await userStatsManager.cacheUserSessions();
for (const [capability, configuration] of Object.entries(
metadata.data.capabilities,
@ -191,6 +193,7 @@ export class ClientHandler {
id,
},
});
await userStatsManager.cacheUserStats();
}
}

View File

@ -1,5 +1,6 @@
import type { GameVersionModel } from "~/prisma/client/models";
import prisma from "../db/database";
import { sum } from "~/utils/array";
export type DropChunk = {
permissions: number;
@ -102,6 +103,14 @@ class ManifestGenerator {
return manifest;
}
calculateManifestSize(manifest: DropManifest) {
return sum(
Object.values(manifest)
.map((chunk) => chunk.lengths)
.flat(),
);
}
}
export const manifestGenerator = new ManifestGenerator();

View File

@ -0,0 +1,236 @@
import cacheHandler from "../cache";
import prisma from "../db/database";
import manifestGenerator from "../downloads/manifest";
import { sum } from "../../../utils/array";
import type { Game, GameVersion } from "~/prisma/client/client";
export type GameSize = {
gameName: string;
size: number;
gameId: string;
};
export type VersionSize = GameSize & {
latest: boolean;
};
type VersionsSizes = {
[versionName: string]: VersionSize;
};
type GameVersionsSize = {
[gameId: string]: VersionsSizes;
};
class GameSizeManager {
private gameVersionsSizesCache =
cacheHandler.createCache<GameVersionsSize>("gameVersionsSizes");
// All versions sizes combined
private gameSizesCache = cacheHandler.createCache<GameSize>("gameSizes");
private async clearGameVersionsSizesCache() {
(await this.gameVersionsSizesCache.getKeys()).map((key) =>
this.gameVersionsSizesCache.remove(key),
);
}
private async clearGameSizesCache() {
(await this.gameSizesCache.getKeys()).map((key) =>
this.gameSizesCache.remove(key),
);
}
// All versions of a game combined
async getCombinedGameSize(gameId: string) {
const versions = await prisma.gameVersion.findMany({
where: { gameId },
});
const sizes = await Promise.all(
versions.map((version) =>
manifestGenerator.calculateManifestSize(
JSON.parse(version.dropletManifest as string),
),
),
);
return sum(sizes);
}
async getGameVersionSize(
gameId: string,
versionName?: string,
): Promise<number | null> {
if (!versionName) {
const version = await prisma.gameVersion.findFirst({
where: { gameId },
orderBy: {
versionIndex: "desc",
},
});
if (!version) {
return null;
}
versionName = version.versionName;
}
const manifest = await manifestGenerator.generateManifest(
gameId,
versionName,
);
if (!manifest) {
return null;
}
return manifestGenerator.calculateManifestSize(manifest);
}
private async isLatestVersion(
gameVersions: GameVersion[],
version: GameVersion,
): Promise<boolean> {
return gameVersions.length > 0
? gameVersions[0].versionName === version.versionName
: false;
}
async getBiggestGamesLatestVersion(top: number): Promise<VersionSize[]> {
const gameIds = await this.gameVersionsSizesCache.getKeys();
const latestGames = await Promise.all(
gameIds.map(async (gameId) => {
const versionsSizes = await this.gameVersionsSizesCache.get(gameId);
if (!versionsSizes) {
return null;
}
const latestVersionName = Object.keys(versionsSizes).find(
(versionName) => versionsSizes[versionName].latest,
);
if (!latestVersionName) {
return null;
}
return versionsSizes[latestVersionName] || null;
}),
);
return latestGames
.filter((game) => game !== null)
.sort((gameA, gameB) => gameB.size - gameA.size)
.slice(0, top);
}
async isGameVersionsSizesCacheEmpty() {
return (await this.gameVersionsSizesCache.getKeys()).length === 0;
}
async isGameSizesCacheEmpty() {
return (await this.gameSizesCache.getKeys()).length === 0;
}
async cacheAllCombinedGames() {
await this.clearGameSizesCache();
const games = await prisma.game.findMany({ include: { versions: true } });
await Promise.all(games.map((game) => this.cacheCombinedGame(game)));
}
async cacheCombinedGame(game: Game) {
const size = await this.getCombinedGameSize(game.id);
if (!size) {
this.gameSizesCache.remove(game.id);
return;
}
const gameSize = {
size,
gameName: game.mName,
gameId: game.id,
};
await this.gameSizesCache.set(game.id, gameSize);
}
async cacheAllGameVersions() {
await this.clearGameVersionsSizesCache();
const games = await prisma.game.findMany({
include: {
versions: {
orderBy: {
versionIndex: "desc",
},
take: 1,
},
},
});
await Promise.all(games.map((game) => this.cacheGameVersion(game)));
}
async cacheGameVersion(
game: Game & { versions: GameVersion[] },
versionName?: string,
) {
const cacheVersion = async (version: GameVersion) => {
const size = await this.getGameVersionSize(game.id, version.versionName);
if (!version.versionName || !size) {
return;
}
const versionsSizes = {
[version.versionName]: {
size,
gameName: game.mName,
gameId: game.id,
latest: await this.isLatestVersion(game.versions, version),
},
};
const allVersionsSizes =
(await this.gameVersionsSizesCache.get(game.id)) || {};
await this.gameVersionsSizesCache.set(game.id, {
...allVersionsSizes,
...versionsSizes,
});
};
if (versionName) {
const version = await prisma.gameVersion.findFirst({
where: { gameId: game.id, versionName },
});
if (!version) {
return;
}
cacheVersion(version);
return;
}
if ("versions" in game) {
await Promise.all(game.versions.map(cacheVersion));
}
}
async getBiggestGamesAllVersions(top: number): Promise<GameSize[]> {
const gameIds = await this.gameSizesCache.getKeys();
const allGames = await Promise.all(
gameIds.map(async (gameId) => await this.gameSizesCache.get(gameId)),
);
return allGames
.filter((game) => game !== null)
.sort((gameA, gameB) => gameB.size - gameA.size)
.slice(0, top);
}
async deleteGameVersion(gameId: string, version: string) {
const game = await prisma.game.findFirst({ where: { id: gameId } });
if (game) {
await this.cacheCombinedGame(game);
}
const versionsSizes = await this.gameVersionsSizesCache.get(gameId);
if (!versionsSizes) {
return;
}
// Remove the version from the VersionsSizes object
const { [version]: _, ...updatedVersionsSizes } = versionsSizes;
await this.gameVersionsSizesCache.set(gameId, updatedVersionsSizes);
}
async deleteGame(gameId: string) {
this.gameSizesCache.remove(gameId);
this.gameVersionsSizesCache.remove(gameId);
}
}
export const manager = new GameSizeManager();
export default manager;

View File

@ -15,6 +15,8 @@ import { GameNotFoundError, type LibraryProvider } from "./provider";
import { logger } from "../logging";
import type { GameModel } from "~/prisma/client/models";
import { createHash } from "node:crypto";
import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
import gameSizeManager from "~/server/internal/gamesize";
export function createGameImportTaskId(libraryId: string, libraryPath: string) {
return createHash("md5")
@ -39,13 +41,19 @@ class LibraryManager {
this.libraries.delete(id);
}
async fetchLibraries() {
async fetchLibraries(): Promise<WorkingLibrarySource[]> {
const libraries = await prisma.library.findMany({});
const libraryWithMetadata = libraries.map((e) => ({
...e,
working: this.libraries.has(e.id),
}));
return libraryWithMetadata;
const libraryWithMetadata = libraries.map(async (library) => {
const theLibrary = this.libraries.get(library.id);
const working = this.libraries.has(library.id);
return {
...library,
working,
fsStats: working ? theLibrary?.fsStats() : undefined,
};
});
return await Promise.all(libraryWithMetadata);
}
async fetchGamesByLibrary() {
@ -334,6 +342,8 @@ class LibraryManager {
acls: ["system:import:version:read"],
});
await libraryManager.cacheCombinedGameSize(gameId);
await libraryManager.cacheGameVersionSize(gameId, versionName);
progress(100);
},
});
@ -363,6 +373,68 @@ class LibraryManager {
if (!library) return undefined;
return await library.readFile(game, version, filename, options);
}
async deleteGameVersion(gameId: string, version: string) {
await prisma.gameVersion.delete({
where: {
gameId_versionName: {
gameId: gameId,
versionName: version,
},
},
});
await gameSizeManager.deleteGameVersion(gameId, version);
}
async deleteGame(gameId: string) {
await prisma.game.delete({
where: {
id: gameId,
},
});
gameSizeManager.deleteGame(gameId);
}
async getGameVersionSize(
gameId: string,
versionName?: string,
): Promise<number | null> {
return gameSizeManager.getGameVersionSize(gameId, versionName);
}
async getBiggestGamesCombinedVersions(top: number) {
if (await gameSizeManager.isGameSizesCacheEmpty()) {
await gameSizeManager.cacheAllCombinedGames();
}
return gameSizeManager.getBiggestGamesAllVersions(top);
}
async getBiggestGamesLatestVersions(top: number) {
if (await gameSizeManager.isGameVersionsSizesCacheEmpty()) {
await gameSizeManager.cacheAllGameVersions();
}
return gameSizeManager.getBiggestGamesLatestVersion(top);
}
async cacheCombinedGameSize(gameId: string) {
const game = await prisma.game.findFirst({ where: { id: gameId } });
if (!game) {
return;
}
await gameSizeManager.cacheCombinedGame(game);
}
async cacheGameVersionSize(gameId: string, versionName: string) {
const game = await prisma.game.findFirst({
where: { id: gameId },
include: { versions: true },
});
if (!game) {
return;
}
await gameSizeManager.cacheGameVersion(game, versionName);
}
}
export const libraryManager = new LibraryManager();

View File

@ -57,6 +57,8 @@ export abstract class LibraryProvider<CFG> {
filename: string,
options?: { start?: number; end?: number },
): Promise<ReadableStream | undefined>;
abstract fsStats(): { freeSpace: number; totalSpace: number } | undefined;
}
export class GameNotFoundError extends Error {}

View File

@ -8,6 +8,7 @@ import { LibraryBackend } from "~/prisma/client/enums";
import fs from "fs";
import path from "path";
import droplet, { DropletHandler } from "@drop-oss/droplet";
import { fsStats } from "~/server/internal/utils/files";
export const FilesystemProviderConfig = type({
baseDir: "string",
@ -122,4 +123,8 @@ export class FilesystemProvider
return stream;
}
fsStats() {
return fsStats(this.config.baseDir);
}
}

View File

@ -6,6 +6,7 @@ import fs from "fs";
import path from "path";
import droplet from "@drop-oss/droplet";
import { DROPLET_HANDLER } from "./filesystem";
import { fsStats } from "~/server/internal/utils/files";
export const FlatFilesystemProviderConfig = type({
baseDir: "string",
@ -113,4 +114,8 @@ export class FlatFilesystemProvider
return stream.getStream();
}
fsStats() {
return fsStats(this.config.baseDir);
}
}

View File

@ -0,0 +1,68 @@
/*
Handles managing collections
*/
import cacheHandler from "../cache";
import prisma from "../db/database";
import { DateTime } from "luxon";
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;
await this.userStatsCache.set("activeSessions", activeSessions);
}
private async cacheUserCount() {
const userCount =
(await prisma.user.count({
where: { id: { not: "system" } },
})) || 0;
await this.userStatsCache.set("userCount", userCount);
}
async cacheUserStats() {
await this.cacheUserSessions();
await this.cacheUserCount();
}
async getUserStats() {
let activeSessions = await this.userStatsCache.get("activeSessions");
let userCount = await this.userStatsCache.get("userCount");
if (activeSessions === null || userCount === null) {
await this.cacheUserStats();
activeSessions = (await this.userStatsCache.get("activeSessions")) || 0;
userCount = (await this.userStatsCache.get("userCount")) || 0;
}
return { activeSessions, userCount };
}
async addUser() {
const userCount = (await this.userStatsCache.get("userCount")) || 0;
await this.userStatsCache.set("userCount", userCount + 1);
}
async deleteUser() {
const userCount = (await this.userStatsCache.get("userCount")) || 1;
await this.userStatsCache.set("userCount", userCount - 1);
await this.cacheUserSessions();
}
}
export const manager = new UserStatsManager();
export default manager;

View File

@ -0,0 +1,47 @@
import fs from "fs";
import nodePath from "path";
export function fsStats(folderPath: string) {
const stats = fs.statfsSync(folderPath);
const freeSpace = stats.bavail * stats.bsize;
const totalSpace = stats.blocks * stats.bsize;
return { freeSpace, totalSpace };
}
export function getFolderSize(folderPath: string): number {
const files = fs.readdirSync(folderPath, { withFileTypes: true });
const paths = files.map((file) => {
const path = nodePath.join(folderPath, file.name);
if (file.isDirectory()) {
return getFolderSize(path);
}
if (file.isFile()) {
return fs.statSync(path).size;
}
return 0;
});
return paths
.flat(Infinity)
.reduce(
(accumulator: number, currentValue: number) => accumulator + currentValue,
0,
);
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes >= 1024 && bytes < Math.pow(1024, 2)) {
return `${(bytes / 1024).toFixed(2)} KiB`;
}
if (bytes >= Math.pow(1024, 2) && bytes < Math.pow(1024, 3)) {
return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
}
if (bytes >= Math.pow(1024, 3) && bytes < Math.pow(1024, 4)) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
}
return `${(bytes / Math.pow(1024, 4)).toFixed(2)} TiB`;
}