feat: show model usage in memory size

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-21 22:53:05 +02:00
1 parent 18d98fcc1a
commit 2813d47049
13 files changed
+160 -4

No files matched your search

+6
View File
@@ -124,6 +124,9 @@ export type PrintOut = {
preview_images: PreviewImageOut[];
thumb_url: string | null;
supporting_file_count: number;
/** Bytes on disk across every model file: all plates plus supporting and prepared files.
* Gallery preview images aren't model files and aren't counted. */
total_size: number;
prepared_print: PreparedPrintOut | null;
slicer_url: string | null;
slicer_filename: string | null;
@@ -233,6 +236,8 @@ export function toPrintOut(
const sortedPlates = plates.toSorted((a, b) => a.position - b.position);
const plateOuts = sortedPlates.map((p) => toPlateOut(print.id, p));
const supportingCount = files.filter((f) => f.role === "SUPPORTING").length;
const totalSize =
plates.reduce((sum, p) => sum + p.size, 0) + files.reduce((sum, f) => sum + f.size, 0);
const previewImageOuts = previewImages
.toSorted((a, b) => a.position - b.position)
.map(toPreviewImageOut)
@@ -290,6 +295,7 @@ export function toPrintOut(
preview_images: previewImageOuts,
thumb_url: plateOuts[0]?.thumb_url ?? null,
supporting_file_count: supportingCount,
total_size: totalSize,
prepared_print: prepared,
slicer_url: slicerUrl,
slicer_filename: slicerFilename,
+11 -1
View File
@@ -3,7 +3,7 @@ import { prisma } from "../db";
import { requireAdmin, requireAuth } from "../auth";
import { HttpError } from "../utils/fileUtils";
import { asyncHandler } from "../utils/asyncHandler";
import { deleteAllPrintsForUser, listLogs, listUsersWithPrintCounts } from "../services/adminService";
import { deleteAllPrintsForUser, getStorageUsage, listLogs, listUsersWithPrintCounts } from "../services/adminService";
const router = Router();
router.use(requireAuth);
@@ -28,6 +28,16 @@ router.get(
}),
);
// Total disk use by models across the whole instance -- shown at the bottom of the Administration
// hub. See getStorageUsage for exactly what's counted.
router.get(
"/admin/storage",
asyncHandler(async (_req, res) => {
const usage = await getStorageUsage();
res.json({ model_bytes: usage.modelBytes, model_count: usage.modelCount });
}),
);
function parseDateParam(raw: unknown): Date | undefined {
if (typeof raw !== "string" || !raw) return undefined;
const parsed = new Date(raw);
+19
View File
@@ -101,3 +101,22 @@ export async function deleteAllPrintsForUser(userId: string): Promise<number> {
}
return prints.length;
}
export type StorageUsage = {
modelBytes: number;
modelCount: number;
};
/** Instance-wide disk use by models, across every user: every plate plus every supporting and
* prepared file -- the same files a model detail page's Size sums for one model (dto.ts's
* total_size), so the two always agree. Read from the stored per-file sizes rather than walking
* the storage directory, so it stays instant however large the library is. Gallery preview
* images and generated caches (thumbnails, 3D previews) aren't model files and aren't counted. */
export async function getStorageUsage(): Promise<StorageUsage> {
const [plates, files, modelCount] = await Promise.all([
prisma.plate.aggregate({ _sum: { size: true } }),
prisma.printFile.aggregate({ _sum: { size: true } }),
prisma.print.count(),
]);
return { modelBytes: (plates._sum.size ?? 0) + (files._sum.size ?? 0), modelCount };
}
+24
View File
@@ -176,3 +176,27 @@ describe("admin audit logs", () => {
expect((res.body as Array<{ user_id: string }>).every((l) => l.user_id === memberUserId)).toBe(true);
});
});
describe("admin storage usage", () => {
it("rejects a non-admin", async () => {
const res = await request(app).get("/api/admin/storage").set(auth(memberToken));
expect(res.status).toBe(403);
});
it("grows by exactly an uploaded model's file size and count", async () => {
const before = await request(app).get("/api/admin/storage").set(auth(adminToken));
expect(before.status).toBe(200);
const content = "solid storage-usage endsolid";
const upload = await request(app)
.post("/api/upload")
.set(auth(memberToken))
.attach("files", tmpFile("admin-storage-test.stl", content));
expect(upload.status).toBe(200);
const after = await request(app).get("/api/admin/storage").set(auth(adminToken));
expect(after.status).toBe(200);
expect(after.body.model_count).toBe(before.body.model_count + 1);
expect(after.body.model_bytes).toBe(before.body.model_bytes + Buffer.byteLength(content));
});
});
+2 -1
View File
@@ -69,7 +69,8 @@ describe("collections routes", () => {
.send({ name: "Renamed Collection", tags: ["c"] });
expect(res.status).toBe(200);
expect(res.body.name).toBe("Renamed Collection");
expect(res.body.tags).toEqual(["c"]);
// Tags are stored in canonical casing (see utils/tagNormalization.ts).
expect(res.body.tags).toEqual(["C"]);
});
it("filters /prints by collection_id (empty collection -> empty list)", async () => {
+2 -1
View File
@@ -116,7 +116,8 @@ describe("importPrintFromUrl -- Printables", () => {
expect(result.print.sourceProvider).toBe("printables");
expect(result.print.sourceExternalId).toBe(MODEL_ID);
expect(result.print.notes).toContain("Mounting");
expect(result.print.tags.toSorted()).toEqual(["garden", "hose"]);
// Tags are stored in canonical casing (see utils/tagNormalization.ts).
expect(result.print.tags.toSorted()).toEqual(["Garden", "Hose"]);
expect(result.print.categoryId).toBe(category.id);
expect(result.plates.map((p) => p.filename)).toEqual(["garden-hose-holder.3mf"]);
+3 -1
View File
@@ -139,7 +139,9 @@ describe("importPrintFromUrl -- Thingiverse", () => {
expect(result.print.sourceProvider).toBe("thingiverse");
expect(result.print.sourceExternalId).toBe(THING_ID);
expect(result.print.notes).toContain("test");
expect(result.print.tags.toSorted()).toEqual(["Test Fixture", "Widget"]);
// Tags are stored in canonical casing (see utils/tagNormalization.ts): only the first
// character stays uppercase, so "Test Fixture" becomes "Test fixture".
expect(result.print.tags.toSorted()).toEqual(["Test fixture", "Widget"]);
expect(result.print.categoryId).toBe(category.id);
// instructions.pdf is not a recognized plate format -- only the two .stl files should have
+11
View File
@@ -37,7 +37,18 @@ export type LogEntry = {
created_at: string;
};
export type StorageUsage = {
model_bytes: number; // every plate + supporting/prepared file, across all users
model_count: number;
};
export const adminApi = {
getStorageUsage: async (): Promise<StorageUsage> => {
const res = await fetch(`${apiBase()}/admin/storage`, { headers: authHeaders() });
assertOk(res, "Failed to load storage usage");
return res.json();
},
listUsers: async (): Promise<AdminUser[]> => {
const res = await fetch(`${apiBase()}/admin/users`, { headers: authHeaders() });
assertOk(res, "Failed to load users");
+3
View File
@@ -72,6 +72,9 @@ export type Print = {
preview_images: PreviewImage[]; // ordered by position; [0] is the default/main gallery image
thumb_url?: string | null; // denormalized = plates[0].thumb_url
supporting_file_count: number;
// Bytes across all plates + supporting/prepared files (not gallery images). Optional: an older
// backend image (published separately from this frontend) doesn't send it.
total_size?: number;
prepared_print?: PreparedPrint | null;
slicer_url?: string | null;
slicer_filename?: string | null;
+7
View File
@@ -255,6 +255,11 @@
}
},
"adminSettings": {
"storageUsage": {
"summary_one": "{{size}} used by {{count}} model",
"summary_other": "{{size}} used by {{count}} models",
"hint": "Disk space used by every user's model files (plates, supporting and prepared files). Thumbnails and gallery images aren't included."
},
"pageTitle": "Admin Settings",
"updateCheck": {
"heading": "Update Checker",
@@ -1158,6 +1163,8 @@
"favoriteFailed": "Failed to update favourites. Please try again.",
"title": "Title",
"category": "Category",
"size": "Size",
"sizeHint": "Total size of all this model's files in storage",
"author": "Author",
"downloadModelFiles": "Download model files",
"imported": "Imported {{date}}"
+8
View File
@@ -255,6 +255,12 @@
}
},
"adminSettings": {
"storageUsage": {
"summary_one": "{{size}} užima {{count}} modelis",
"summary_few": "{{size}} užima {{count}} modeliai",
"summary_other": "{{size}} užima {{count}} modelių",
"hint": "Visų naudotojų modelių failų (plokščių, papildomų ir paruoštų failų) užimama vieta diske. Miniatiūros ir galerijos paveikslėliai neįskaičiuoti."
},
"pageTitle": "Administravimo nustatymai",
"updateCheck": {
"heading": "Atnaujinimų tikrinimas",
@@ -1159,6 +1165,8 @@
"favoriteFailed": "Nepavyko atnaujinti mėgstamų. Bandykite dar kartą.",
"title": "Pavadinimas",
"category": "Kategorija",
"size": "Dydis",
"sizeHint": "Bendras visų šio modelio failų dydis saugykloje",
"author": "Autorius",
"downloadModelFiles": "Atsisiųsti modelio failus",
"imported": "Importuota {{date}}"
+44
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Stack from "@mui/material/Stack";
@@ -6,13 +7,19 @@ import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Skeleton from "@mui/material/Skeleton";
import Typography from "@mui/material/Typography";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import StorageIcon from "@mui/icons-material/Storage";
import SettingsIcon from "@mui/icons-material/Settings";
import PeopleIcon from "@mui/icons-material/People";
import HistoryIcon from "@mui/icons-material/History";
import BoltIcon from "@mui/icons-material/Bolt";
import CableIcon from "@mui/icons-material/Cable";
import UpdateCheckSection from "./UpdateCheckSection";
import { adminApi, type StorageUsage } from "../../api/admin";
import { UnauthorizedError } from "../../api/client";
import { formatFileSize } from "../../utils/fileSize";
type Section = {
path: string;
@@ -41,6 +48,21 @@ type Props = {
export default function AdminPage({ onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
const navigate = useNavigate();
// undefined = loading, null = failed (the footer just hides -- it's informational only).
const [storage, setStorage] = useState<StorageUsage | null | undefined>(undefined);
useEffect(() => {
let cancelled = false;
adminApi.getStorageUsage().then(
usage => { if (!cancelled) setStorage(usage); },
err => {
if (cancelled) return;
if (err instanceof UnauthorizedError) onUnauthorized?.();
setStorage(null);
},
);
return () => { cancelled = true; };
}, [onUnauthorized]);
return (
<Stack spacing={3} sx={{ maxWidth: 480 }}>
@@ -67,6 +89,28 @@ export default function AdminPage({ onUnauthorized }: Props) {
))}
</List>
</Paper>
{storage !== null && (
<Stack
direction="row"
alignItems="center"
spacing={1}
title={t("adminSettings.storageUsage.hint")}
sx={{ px: 0.5, color: "text.secondary" }}
>
<StorageIcon fontSize="small" />
{storage === undefined ? (
<Skeleton width={220} />
) : (
<Typography variant="body2">
{t("adminSettings.storageUsage.summary", {
size: formatFileSize(storage.model_bytes),
count: storage.model_count,
})}
</Typography>
)}
</Stack>
)}
</Stack>
);
}
@@ -8,6 +8,7 @@ import Avatar from "@mui/material/Avatar";
import Button from "@mui/material/Button";
import ButtonBase from "@mui/material/ButtonBase";
import FolderIcon from "@mui/icons-material/Folder";
import StorageIcon from "@mui/icons-material/Storage";
import LaunchIcon from "@mui/icons-material/Launch";
import DownloadIcon from "@mui/icons-material/Download";
import VisibilityIcon from "@mui/icons-material/Visibility";
@@ -23,6 +24,7 @@ import { dividerBorderColor } from "../../theme";
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
import { useDownloadPrint } from "./useDownloadPrint";
import RollingNumber from "../../components/RollingNumber";
import { formatFileSize } from "../../utils/fileSize";
import AuthorHoverCard from "../../components/AuthorHoverCard";
import DownloadPickerDialog from "./DownloadPickerDialog";
@@ -168,6 +170,24 @@ export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized
</Box>
)}
{typeof print.total_size === "number" && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 0.5 }}>
{t("models:detail.size")}
</Typography>
<Stack
direction="row"
alignItems="center"
spacing={0.75}
title={t("models:detail.sizeHint")}
sx={{ width: "fit-content", color: (muiTheme) => muiTheme.thingport.headingText }}
>
<StorageIcon fontSize="small" />
<Typography variant="body2" fontWeight={600}>{formatFileSize(print.total_size)}</Typography>
</Stack>
</Box>
)}
{openInSlicerHref && (
<Button
component="a"