mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 14:12:25 -05:00
feat: show model usage in memory size
This commit is contained in:
1 parent
18d98fcc1a
commit
2813d47049
13 files changed
+160
-4
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user