ac7ef266dc
* fix(admin): make "Storage used" report storage used (#1164) Stable twin of #1170. The tile summed photos.size_bytes — the catalogued size of the ORIGINALS, which in reference mode live on external storage and have no relationship to the disk PicPeak runs on. The reporter's tile read ~80 GB against 21 GB of real usage. Worse than the label: the same number drove the soft-limit warning bar and, via /storage/info, the recommended soft limit — so a reference-mode install got a disk-capacity recommendation computed from bytes that are not on the disk. - new localStorageUsage service walks the storage root and reports the total plus a breakdown. Walking rather than summing DB columns is the point: thumbnail/preview/hero rows record a key and never a byte count, and orphans from a deleted event or an interrupted import are real bytes. - the external media root is excluded when it sits inside the storage root. Its compose default is <storage>/external-media, where the NAS is bind-mounted — a plain directory, not a symlink — so walking it would put every referenced original back into a figure whose purpose is to leave them out. Symlinks are not followed either. - .download-cache gets its own line: it lives inside the event directory, so the naive rule files a multi-GB zip as photography. - concurrent cold-cache callers share one walk; the dashboard, /storage/info and the sidebar are routinely requested together. - S3 installs keep the catalogued figure and the walk is skipped before it runs, since the objects are in the bucket and STORAGE_PATH holds only incidental local files. - an absent measurement reads as "unavailable" and a partial one is marked `+` across the dashboard, analytics, sidebar and status tab — a floor silently compared against a soft limit reads as "safely under". Verified on this branch: 11 new service tests, dashboardScope updated for the changed contract, full suite leaves the same 5 pre-existing failures as origin/stable. Frontend 20 files / 104 tests, tsc clean. * fix(admin): tell "no disk to measure" apart from "the measurement failed" (#1164) External review found both of these on this branch. Both were reported as `storage_measurement: 'catalog'`, so a failed local walk made the dashboard claim the objects live in S3. They are different things — one is a fact about the install, the other is a fault — and there is now an `unavailable` state for the second. The analytics percentage could reach the billions. `safeSoftLimit` fell back to `storageUsed || 1`, and on S3 that is null → 1, while the figure beside it came from `catalogedBytes`. An editor or viewer holds `analytics.view` but not `settings.view`, so `/storage/info` 403s for them and `storageInfo` is undefined — which is exactly when that fallback fires. It now falls back to the measured figure, and suppresses the percentage entirely when there is no real limit rather than dividing usage by itself and always reading 100%. --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
202 lines
8.0 KiB
JavaScript
202 lines
8.0 KiB
JavaScript
/**
|
|
* "Storage used" has to mean storage used (#1164).
|
|
*
|
|
* The tile summed photos.size_bytes, so on a reference-mode install it
|
|
* reported the size of files sitting on a NAS — the reporter's read ~80 GB
|
|
* against 21 GB of real local usage — while omitting everything PicPeak does
|
|
* write locally, including an 11.8 GB download-cache zip.
|
|
*
|
|
* These pin the measurement against a real directory tree, since the whole
|
|
* point is counting bytes that are actually there.
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
|
|
const {
|
|
measureLocalStorageUsage,
|
|
resetLocalStorageUsageCache,
|
|
} = require('../../src/services/localStorageUsage');
|
|
|
|
describe('localStorageUsage (#1164)', () => {
|
|
let root;
|
|
|
|
const write = async (rel, bytes) => {
|
|
const full = path.join(root, rel);
|
|
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
|
await fs.promises.writeFile(full, Buffer.alloc(bytes));
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-usage-'));
|
|
process.env.STORAGE_PATH = root;
|
|
delete process.env.EXTERNAL_MEDIA_ROOT;
|
|
jest.resetModules();
|
|
resetLocalStorageUsageCache();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.promises.rm(root, { recursive: true, force: true }).catch(() => {});
|
|
delete process.env.STORAGE_PATH;
|
|
delete process.env.EXTERNAL_MEDIA_ROOT;
|
|
resetLocalStorageUsageCache();
|
|
});
|
|
|
|
it('counts every byte under the storage root', async () => {
|
|
await write(path.join('events', 'active', 'wed', 'individual', 'a.jpg'), 1000);
|
|
await write(path.join('thumbnails', 'a.jpg'), 100);
|
|
await write(path.join('previews', 'a.jpg'), 300);
|
|
|
|
const usage = await measureLocalStorageUsage();
|
|
|
|
expect(usage.total).toBe(1400);
|
|
expect(usage.files).toBe(3);
|
|
});
|
|
|
|
it('breaks the total down by what the bytes are', async () => {
|
|
// The specific complaint: the derived artefacts PicPeak writes were
|
|
// invisible, so "what is filling my disk" had no answer in the UI.
|
|
await write(path.join('events', 'active', 'wed', 'individual', 'a.jpg'), 1000);
|
|
await write(path.join('events', 'archived', 'old.zip'), 5000);
|
|
await write(path.join('thumbnails', 'a.jpg'), 100);
|
|
await write(path.join('previews', 'a.jpg'), 300);
|
|
await write(path.join('heroes', 'a.jpg'), 200);
|
|
await write(path.join('watermarks', 'a.jpg'), 700);
|
|
await write(path.join('uploads', 'logo.png'), 50);
|
|
|
|
const { breakdown } = await measureLocalStorageUsage();
|
|
|
|
expect(breakdown).toMatchObject({
|
|
originals: 1000,
|
|
archives: 5000,
|
|
thumbnails: 100,
|
|
previews: 300,
|
|
heroes: 200,
|
|
watermarks: 700,
|
|
uploads: 50,
|
|
});
|
|
});
|
|
|
|
it('files the download cache separately from the originals it sits among', async () => {
|
|
// `.download-cache` lives INSIDE the event directory, so the naive rule
|
|
// files an 11.8 GB zip as photography. It is the one bucket that is pure
|
|
// disposable cache and the one an admin most needs to see.
|
|
await write(path.join('events', 'active', 'wed', 'individual', 'a.jpg'), 1000);
|
|
await write(path.join('events', 'active', 'wed', '.download-cache', 'all.zip'), 9000);
|
|
|
|
const { breakdown, total } = await measureLocalStorageUsage();
|
|
|
|
expect(breakdown.downloadCache).toBe(9000);
|
|
expect(breakdown.originals).toBe(1000);
|
|
expect(total).toBe(10000);
|
|
});
|
|
|
|
it('counts orphans no database row knows about', async () => {
|
|
// A deleted event's leftovers and an interrupted import's thumbnails are
|
|
// real bytes on a real disk. Summing DB columns would miss them, which is
|
|
// half of why this walks instead.
|
|
await write(path.join('thumbnails', 'ext999_gone.jpg'), 777);
|
|
|
|
expect((await measureLocalStorageUsage()).total).toBe(777);
|
|
});
|
|
|
|
it('reports zero on a fresh install rather than failing', async () => {
|
|
const usage = await measureLocalStorageUsage();
|
|
|
|
expect(usage.total).toBe(0);
|
|
expect(usage.partial).toBe(false);
|
|
});
|
|
|
|
it('survives a storage root that does not exist', async () => {
|
|
process.env.STORAGE_PATH = path.join(root, 'nope');
|
|
resetLocalStorageUsageCache();
|
|
|
|
const usage = await measureLocalStorageUsage();
|
|
|
|
// ENOENT on the root is a fresh/misconfigured install, not a partial read.
|
|
expect(usage.total).toBe(0);
|
|
expect(usage.partial).toBe(false);
|
|
});
|
|
|
|
it('does not walk the media share bind-mounted under the storage root', async () => {
|
|
// The compose default puts EXTERNAL_MEDIA_ROOT at <storage>/external-media,
|
|
// where the NAS is bind-mounted — a plain directory, not a symlink. Walking
|
|
// it would put every referenced original back into a figure that exists to
|
|
// leave them out, which is the over-count this measurement replaces.
|
|
await write(path.join('thumbnails', 'a.jpg'), 100);
|
|
await write(path.join('external-media', 'nas', 'huge.jpg'), 50000);
|
|
process.env.EXTERNAL_MEDIA_ROOT = path.join(root, 'external-media');
|
|
jest.resetModules();
|
|
const svc = require('../../src/services/localStorageUsage');
|
|
svc.resetLocalStorageUsageCache();
|
|
|
|
const usage = await svc.measureLocalStorageUsage();
|
|
|
|
expect(usage.total).toBe(100);
|
|
expect(usage.excludedExternalRoot).toBe(path.join(root, 'external-media'));
|
|
});
|
|
|
|
it('still counts a directory that merely looks like the media share', async () => {
|
|
// Only the CONFIGURED root is skipped. An install whose media lives
|
|
// elsewhere keeps whatever is in this directory in the total, because
|
|
// those really are local bytes.
|
|
await write(path.join('external-media', 'leftover.jpg'), 700);
|
|
// The production shape: the share is mounted well outside the storage root.
|
|
const elsewhere = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-nas-elsewhere-'));
|
|
process.env.EXTERNAL_MEDIA_ROOT = elsewhere;
|
|
jest.resetModules();
|
|
const svc = require('../../src/services/localStorageUsage');
|
|
svc.resetLocalStorageUsageCache();
|
|
|
|
const usage = await svc.measureLocalStorageUsage();
|
|
|
|
expect(usage.total).toBe(700);
|
|
expect(usage.excludedExternalRoot).toBeNull();
|
|
await fs.promises.rm(elsewhere, { recursive: true, force: true });
|
|
});
|
|
|
|
it('shares one walk between concurrent cold-cache callers', async () => {
|
|
// /dashboard/stats and /storage/info are routinely requested together, and
|
|
// the sidebar adds a third. Each starting its own full stat-per-file walk
|
|
// multiplies the cost on exactly the large libraries where it hurts.
|
|
await write(path.join('thumbnails', 'a.jpg'), 100);
|
|
const readdir = jest.spyOn(fs.promises, 'readdir');
|
|
|
|
const [a, b, c] = await Promise.all([
|
|
measureLocalStorageUsage(),
|
|
measureLocalStorageUsage(),
|
|
measureLocalStorageUsage(),
|
|
]);
|
|
|
|
expect([a.total, b.total, c.total]).toEqual([100, 100, 100]);
|
|
// One walk: the storage root plus its one subdirectory.
|
|
expect(readdir).toHaveBeenCalledTimes(2);
|
|
readdir.mockRestore();
|
|
});
|
|
|
|
it('caches, and honours force', async () => {
|
|
await write(path.join('thumbnails', 'a.jpg'), 100);
|
|
expect((await measureLocalStorageUsage()).total).toBe(100);
|
|
|
|
await write(path.join('thumbnails', 'b.jpg'), 400);
|
|
// One stat per file is not free; the dashboard polls.
|
|
expect((await measureLocalStorageUsage()).total).toBe(100);
|
|
expect((await measureLocalStorageUsage({ force: true })).total).toBe(500);
|
|
});
|
|
|
|
it('does not follow a symlink out of the storage root', async () => {
|
|
// A link into EXTERNAL_MEDIA_ROOT would add the NAS back into the local
|
|
// total — reinstating the exact confusion this replaces.
|
|
const outside = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-nas-'));
|
|
await fs.promises.writeFile(path.join(outside, 'huge.jpg'), Buffer.alloc(50000));
|
|
await fs.promises.mkdir(path.join(root, 'events'), { recursive: true });
|
|
await fs.promises.symlink(outside, path.join(root, 'events', 'nas'), 'dir');
|
|
|
|
const usage = await measureLocalStorageUsage();
|
|
|
|
expect(usage.total).toBe(0);
|
|
await fs.promises.rm(outside, { recursive: true, force: true });
|
|
});
|
|
});
|