fix(admin): make "Storage used" report storage used (#1164) (#1177)

* 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 <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-26 09:14:48 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 9ffbe2f98f
commit ac7ef266dc
15 changed files with 634 additions and 25 deletions
@@ -361,7 +361,9 @@ const StorageInfo: React.FC = () => {
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-700 dark:text-neutral-300">{t('admin.storageUsed')}</span>
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{settingsService.formatBytes(storageInfo.total_used)}
{/* `+` marks a floor: part of the storage root was unreadable, so
the real figure — and the percentage below — is higher (#1164). */}
{settingsService.formatBytes(storageInfo.total_used)}{storageInfo.storage_partial ? '+' : ''}
</span>
</div>
<div className="mt-2 w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
@@ -229,7 +229,10 @@ export const StatusTab: React.FC<StatusTabProps> = ({
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.storage.totalUsed')}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{settingsService.formatBytes(storageInfo.total_used)}
{/* `+` marks a floor: part of the storage root was
unreadable, so the real figure — and the limit
percentage derived from it — is higher (#1164). */}
{settingsService.formatBytes(storageInfo.total_used)}{storageInfo.storage_partial ? '+' : ''}
</p>
</div>
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
+8 -2
View File
@@ -2834,7 +2834,10 @@
"email_template_created": "E-Mail-Vorlage erstellt: {{template_key}}",
"event_duplicated": "Event dupliziert aus {{source_event_name}}",
"whatsapp_config_updated": "WhatsApp-Konfiguration aktualisiert"
}
},
"catalogedMedia": "{{size}} katalogisiert",
"catalogedMediaOnly": "katalogisiert — Objekte liegen in S3",
"storageUnavailable": "nicht verfügbar"
},
"acceptInvitation": {
"title": "Einladung annehmen",
@@ -2955,7 +2958,10 @@
"totalPhotos": "Gesamte Fotos",
"activeEvents": "Aktive Veranstaltungen",
"notConfigured": "Umami Analytics nicht konfiguriert",
"configureInstructions": "Um echte Analysedaten zu sehen, konfigurieren Sie Umami in Ihren Umgebungsvariablen und Admin-Panel-Einstellungen."
"configureInstructions": "Um echte Analysedaten zu sehen, konfigurieren Sie Umami in Ihren Umgebungsvariablen und Admin-Panel-Einstellungen.",
"catalogedMedia": "Katalogisierte Medien",
"storageUnavailable": "nicht verfügbar",
"storageNoMeasurement": "keine Messung verfügbar"
},
"email": {
"title": "E-Mail-Konfiguration",
+8 -2
View File
@@ -1650,7 +1650,10 @@
"totalPhotos": "Total Photos",
"activeEvents": "Active Events",
"notConfigured": "Umami Analytics Not Configured",
"configureInstructions": "To see real analytics data, configure Umami in your environment variables and admin panel settings."
"configureInstructions": "To see real analytics data, configure Umami in your environment variables and admin panel settings.",
"catalogedMedia": "Catalogued media",
"storageUnavailable": "unavailable",
"storageNoMeasurement": "no measurement available"
},
"branding": {
"title": "Branding & Themes",
@@ -2406,7 +2409,10 @@
"email_template_created": "Email template created: {{template_key}}",
"event_duplicated": "Event duplicated from {{source_event_name}}",
"whatsapp_config_updated": "WhatsApp configuration updated"
}
},
"catalogedMedia": "{{size}} catalogued",
"catalogedMediaOnly": "catalogued — objects live in S3",
"storageUnavailable": "unavailable"
},
"acceptInvitation": {
"title": "Accept Invitation",
+8 -2
View File
@@ -1213,7 +1213,10 @@
"totalPhotos": "Photos totales",
"activeEvents": "Événements actifs",
"notConfigured": "Umami Analytics non configuré",
"configureInstructions": "Pour voir les données analytiques réelles, configurez Umami dans vos variables d'environnement et les paramètres du panneau d'administration."
"configureInstructions": "Pour voir les données analytiques réelles, configurez Umami dans vos variables d'environnement et les paramètres du panneau d'administration.",
"catalogedMedia": "Médias catalogués",
"storageUnavailable": "indisponible",
"storageNoMeasurement": "aucune mesure disponible"
},
"branding": {
"title": "Marque & Thèmes",
@@ -1817,7 +1820,10 @@
"category_hero_updated": "Photo de couverture de catégorie mise à jour",
"public_site_reset_to_default": "Site public réinitialisé aux valeurs par défaut",
"cms_page_logo_uploaded": "Logo de la page CMS téléversé : {{slug}}"
}
},
"catalogedMedia": "{{size}} catalogués",
"catalogedMediaOnly": "catalogué — les objets sont dans S3",
"storageUnavailable": "indisponible"
},
"acceptInvitation": {
"title": "Accepter l'invitation",
+8 -2
View File
@@ -1213,7 +1213,10 @@
"totalPhotos": "Skupaj fotografij",
"activeEvents": "Aktivni dogodki",
"notConfigured": "Umami Analytics ni nastavljen",
"configureInstructions": "Za prikaz resničnih analitičnih podatkov nastavite Umami v okoljskih spremenljivkah in nastavitvah administratorske plošče."
"configureInstructions": "Za prikaz resničnih analitičnih podatkov nastavite Umami v okoljskih spremenljivkah in nastavitvah administratorske plošče.",
"catalogedMedia": "Katalogizirani mediji",
"storageUnavailable": "ni na voljo",
"storageNoMeasurement": "meritev ni na voljo"
},
"branding": {
"title": "Blagovna znamka in teme",
@@ -1806,7 +1809,10 @@
"category_hero_updated": "Hero fotografija kategorije posodobljena",
"public_site_reset_to_default": "Javna stran ponastavljena na privzeto",
"cms_page_logo_uploaded": "Logotip CMS strani naložen: {{slug}}"
}
},
"catalogedMedia": "{{size}} katalogizirano",
"catalogedMediaOnly": "katalogizirano — objekti so v S3",
"storageUnavailable": "ni na voljo"
},
"acceptInvitation": {
"title": "Sprejmi povabilo",
+14 -1
View File
@@ -169,8 +169,21 @@ export const AdminDashboard: React.FC = () => {
color: 'text-blue-600',
},
{
// Real bytes under the storage root (#1164). This used to be the summed
// size of the catalogued originals, which on a reference-mode install is
// the size of a NAS. `== null` rather than `||`: an absent measurement
// must read as unavailable, not as 0 Bytes.
title: t('admin.storageUsed'),
value: adminService.formatBytes(dashboardStats?.storageUsed || 0),
value: dashboardStats?.storageUsed == null
? (dashboardStats?.storageMeasurement === 'catalog'
? adminService.formatBytes(dashboardStats.catalogedBytes)
: t('admin.storageUnavailable', 'unavailable'))
: `${adminService.formatBytes(dashboardStats.storageUsed)}${dashboardStats.storagePartial ? '+' : ''}`,
change: dashboardStats
? (dashboardStats.storageMeasurement === 'catalog'
? t('admin.catalogedMediaOnly', 'catalogued — objects live in S3')
: t('admin.catalogedMedia', { size: adminService.formatBytes(dashboardStats.catalogedBytes) }))
: undefined,
icon: HardDrive,
color: 'text-purple-600',
},
+33 -8
View File
@@ -438,15 +438,30 @@ export const AnalyticsPage: React.FC = () => {
{/* Storage Information */}
{dashboardStats && (() => {
// On S3 there is no disk to walk and the catalogued figure IS the
// available answer; a failed local walk has none at all.
const measured = dashboardStats.storageUsed ?? (
dashboardStats.storageMeasurement === 'catalog' ? dashboardStats.catalogedBytes : null);
const softLimitBytes = storageInfo?.storage_soft_limit ?? storageInfo?.storage_limit ?? storageInfo?.recommended_soft_limit ?? null;
// `measured`, not storageUsed. An editor or viewer holds
// analytics.view but not settings.view, so /storage/info 403s and
// storageInfo is undefined — and on S3 storageUsed is null, which
// made this denominator 1 and rendered percentages in the billions.
const safeSoftLimit = Math.max(
softLimitBytes ?? storageInfo?.recommended_soft_limit ?? (dashboardStats.storageUsed || 1),
softLimitBytes ?? storageInfo?.recommended_soft_limit ?? (measured || 1),
1
);
const usageRatio = dashboardStats.storageUsed / safeSoftLimit;
const usagePercent = Math.round(usageRatio * 100);
const usageWidth = Math.min(usageRatio * 100, 100);
const overSoftLimit = softLimitBytes != null && dashboardStats.storageUsed >= softLimitBytes;
// Without a real limit there is no percentage worth showing: the
// denominator would be the usage itself, which always reads 100%.
const hasLimit = softLimitBytes != null || storageInfo?.recommended_soft_limit != null;
// No measurement, or no limit, means no percentage. Coercing null
// to 0 drew an empty bar at "0% of limit" and suppressed the
// over-limit state — reading as plenty of room precisely when
// nothing is known (#1164).
const usageRatio = (measured == null || !hasLimit) ? null : measured / safeSoftLimit;
const usagePercent = usageRatio == null ? null : Math.round(usageRatio * 100);
const usageWidth = usageRatio == null ? 0 : Math.min(usageRatio * 100, 100);
const overSoftLimit = softLimitBytes != null && measured != null && measured >= softLimitBytes;
const limitDisplay = softLimitBytes != null
? adminService.formatBytes(softLimitBytes)
: storageInfo?.recommended_soft_limit != null
@@ -454,7 +469,7 @@ export const AnalyticsPage: React.FC = () => {
: t('settings.storage.unlimited');
const progressColor = overSoftLimit
? 'bg-red-600'
: usagePercent >= 90
: (usagePercent != null && usagePercent >= 90)
? 'bg-amber-500'
: 'bg-accent-dark';
const limitDescriptor = storageInfo
@@ -470,7 +485,11 @@ export const AnalyticsPage: React.FC = () => {
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-600 dark:text-neutral-400">{t('analytics.used')}</span>
<span className="font-medium text-neutral-900 dark:text-neutral-100">{adminService.formatBytes(dashboardStats.storageUsed)}</span>
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{measured == null
? t('analytics.storageUnavailable', 'unavailable')
: `${adminService.formatBytes(measured)}${dashboardStats.storagePartial ? '+' : ''}`}
</span>
</div>
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
<div
@@ -479,7 +498,9 @@ export const AnalyticsPage: React.FC = () => {
/>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{usagePercent}% {t('analytics.of')} {limitDisplay}
{usagePercent == null
? t('analytics.storageNoMeasurement', 'no measurement available')
: `${usagePercent}% ${t('analytics.of')} ${limitDisplay}`}
</p>
<p className={`text-xs mt-1 ${overSoftLimit ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-red-500 dark:text-red-400 font-medium'}`}>
{limitDescriptor}
@@ -487,6 +508,10 @@ export const AnalyticsPage: React.FC = () => {
</div>
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
<div className="flex justify-between text-sm">
<span className="text-neutral-600 dark:text-neutral-400">{t('analytics.catalogedMedia', 'Catalogued media')}</span>
<span className="font-medium text-neutral-900 dark:text-neutral-100">{adminService.formatBytes(dashboardStats.catalogedBytes)}</span>
</div>
<div className="flex justify-between text-sm mt-2">
<span className="text-neutral-600 dark:text-neutral-400">{t('analytics.totalPhotos')}</span>
<span className="font-medium text-neutral-900 dark:text-neutral-100">{dashboardStats.totalPhotos.toLocaleString()}</span>
</div>
+18 -1
View File
@@ -61,7 +61,24 @@ export interface DashboardStats {
activeEvents: number;
expiringEvents: number;
totalPhotos: number;
storageUsed: number;
// Real bytes under the storage root — thumbnails, previews, hero
// renditions, watermarks, download caches and any managed originals
// (#1164). Null when the measurement failed, or when the backend is S3 and
// the objects live in the bucket. The UI must show that as unavailable
// rather than substituting `catalogedBytes`: they are different quantities
// and on a reference-mode install they are wildly different.
storageUsed: number | null;
// 'catalog' when the backend is S3 — the objects are in the bucket, so no
// disk walk was made. 'unavailable' when a local walk was attempted and
// failed. Distinct because the first is a fact about the install and the
// second is a fault, and the UI must not claim S3 for a broken measurement.
storageMeasurement?: 'disk' | 'catalog' | 'unavailable';
storageBreakdown: Record<string, number> | null;
// The total is a floor: part of the storage root could not be read.
storagePartial?: boolean;
// Summed photos.size_bytes — what this endpoint used to label "storage
// used". In reference mode those files are on external storage.
catalogedBytes: number;
totalViews: number;
totalDownloads: number;
viewsTrend: number;
@@ -81,7 +81,15 @@ export interface PasswordComplexitySettings {
}
export interface StorageInfo {
// Real bytes under the storage root (#1164), excluding the external media
// share. Was the summed size of the catalogued originals, which on a
// reference-mode install is the size of a NAS.
total_used: number;
cataloged_bytes?: number;
// True when part of the storage root could not be read, so total_used is a
// floor. Anything comparing it against a limit has to say so.
storage_partial?: boolean;
storage_measurement?: 'disk' | 'catalog' | 'unavailable';
archive_storage: number;
storage_by_event: Array<{
event_name: string;