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

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

The tile summed photos.size_bytes — the catalogued size of the ORIGINALS,
which has no relationship to the disk PicPeak runs on. In reference mode those
files are never copied and sit on the NAS; duplicate rows counted the same
file twice (#1162); and it ignored everything PicPeak genuinely does write
locally: thumbnails, previews, hero renditions, watermarks and the per-event
download cache. The reporter's tile read ~80 GB against 21 GB of real usage.

Worse than the label: the same number drove the storage 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. Symlinks are
  not followed, so a link into the media mount cannot put the NAS back in the
  total. Cached for 5 minutes, since the dashboard polls.
- the dashboard tile and /storage/info now report that, with the catalogued
  figure kept and labelled as such next to it. A failed measurement reads as
  "unavailable" rather than substituting a number that means something else.

On the local rig: 64.37 MB used against 15.75 MB catalogued, of which 27.9 MB
is watermarks and 6.8 MB is download cache — none of which the old figure
could see.

Not addressed here: `.download-cache/all.zip` still has no TTL or size cap. It
is now at least visible in the breakdown, which is what makes the case for
capping it.

* fix(admin): exclude the media share from local storage usage (#1164)

External review found the walk could reintroduce the exact over-count it
replaces.

EXTERNAL_MEDIA_ROOT's compose default is `<storage>/external-media`, where the
NAS is bind-mounted. That is a plain directory, not a symlink, so the symlink
guard did not cover it and the walk descended into the share — putting every
referenced original back into a figure whose whole purpose is to leave them
out, and comparing NAS bytes against statfs() of the local disk. On the
reference-mode installs this issue is about, that is the failure mode
reappearing inside its own fix.

The configured root is now skipped when it lies inside the storage root, and
the result reports which path was excluded. A directory that merely shares the
name is still counted, because those really are local bytes.

Also from the review:

- concurrent cold-cache callers now share one walk. /dashboard/stats,
  /storage/info and the sidebar are routinely requested together, and each was
  starting its own stat-per-file traversal of the whole library.
- storage_partial is surfaced in the StorageInfo type and the sidebar tile, not
  just the dashboard and analytics cards. An unreadable subtree makes the total
  a floor, and a floor silently compared against a soft limit reads as "safely
  under".

* fix(admin): do not report a disk walk on an S3 backend (#1164)

Second review round.

S3 installs were regressed. With STORAGE_BACKEND=s3 the originals, renditions,
archives and download caches are objects in the bucket and STORAGE_PATH holds
only incidental local files — so the walk reported near-zero and the soft-limit
recommendation was derived from it. Those installs now keep the catalogued
figure, which is the approximation they had before this PR, and the response
says which measurement it is (`storage_measurement: 'disk' | 'catalog'`) so the
UI labels it instead of implying a disk measurement that never happened.

The Settings → Status storage card ignored storage_partial, formatting a lower
bound as exact and deriving the limit percentage from it — so an unreadable
subtree could read as safely under the limit. It now carries the same `+`
marker as the sidebar and dashboard.

* fix(admin): stop rendering an absent measurement as zero usage (#1164)

Third review round, two findings.

The analytics storage bar coerced a null measurement to 0, drawing an empty
bar labelled "0% of limit" and suppressing the over-limit state — reading as
plenty of room at exactly the moment nothing is known. It now shows the
catalogued figure on S3, where that IS the available answer, and says "no
measurement available" rather than inventing a percentage when there is none.

/storage/info walked the filesystem before checking the backend and then threw
the result away on S3. The sidebar polls that endpoint, so a migrated install
still holding a large local tree paid a full stat-per-file traversal on every
cold cache for nothing. Gated before the walk, as the dashboard route already
was.

* fix(admin): tell "no disk to measure" apart from "the measurement failed" (#1164)

External review of the stable twin.

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%.

Also lands the AnalyticsPage half of the previous round, which the commit
message claimed but the commit did not contain — only its backend counterpart
was staged. The stable twin has carried it since it was written, so this is the
parity gap in the unusual direction.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-26 08:53:39 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 1366d6d14c
commit 849a5807b7
19 changed files with 723 additions and 34 deletions
@@ -379,7 +379,11 @@ 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)}
{/* The `+` marks a floor: part of the storage root was unreadable,
so the real figure — and the percentage below — is higher than
this. Without it an EACCES subtree reads as "safely under the
limit" (#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">
@@ -228,8 +228,16 @@ 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>
{storageInfo.storage_measurement === 'catalog' && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.storage.catalogMeasurement', 'Catalogued size — objects live in the configured S3 bucket, not on this disk')}
</p>
)}
</div>
<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.archiveStorage')}</p>
+8 -2
View File
@@ -3223,7 +3223,10 @@
"sidecarStateNow": "Aktueller Zustand des Dienstes — einige der Fehler oben können eine andere Ursache haben, aber ein erneuter Scan wird erst nach der Behebung erfolgreich sein:",
"consolidated_one": "Beim letzten Scan wurde {{count}} ähnliches Paar automatisch zusammengeführt. Prüfen Sie es unter „Personen verwalten“ — falsch Zusammengeführtes lässt sich mit „Trennen“ wieder aufteilen.",
"consolidated_other": "Beim letzten Scan wurden {{count}} ähnliche Paare automatisch zusammengeführt. Prüfen Sie sie unter „Personen verwalten“ — falsch Zusammengeführtes lässt sich mit „Trennen“ wieder aufteilen."
}
},
"catalogedMedia": "{{size}} katalogisiert",
"storageUnavailable": "nicht verfügbar",
"catalogedMediaOnly": "katalogisiert — Objekte liegen in S3"
},
"acceptInvitation": {
"title": "Einladung annehmen",
@@ -3345,7 +3348,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
@@ -1935,7 +1935,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",
@@ -2795,7 +2798,10 @@
"sidecarStateNow": "Service state right now — some of the failures above may have a different cause, but a re-scan will not succeed until this is fixed:",
"consolidated_one": "Grouping merged {{count}} look-alike pair automatically after the last scan. Open Manage people to check it — anything merged wrongly can be separated again with Split.",
"consolidated_other": "Grouping merged {{count}} look-alike pairs automatically after the last scan. Open Manage people to check them — anything merged wrongly can be separated again with Split."
}
},
"catalogedMedia": "{{size}} catalogued",
"storageUnavailable": "unavailable",
"catalogedMediaOnly": "catalogued — objects live in S3"
},
"acceptInvitation": {
"title": "Accept Invitation",
+8 -2
View File
@@ -1109,7 +1109,10 @@
"notConfigured": "Umami no está configurado",
"configureInstructions": "Para ver datos reales, configura Umami en tus variables de entorno y en los ajustes.",
"noData": "No hay datos disponibles",
"percentChange": "{{percent}}% respecto al periodo anterior"
"percentChange": "{{percent}}% respecto al periodo anterior",
"catalogedMedia": "Medios catalogados",
"storageUnavailable": "no disponible",
"storageNoMeasurement": "sin medición disponible"
},
"branding": {
"title": "Marca y temas",
@@ -1598,7 +1601,10 @@
"detail": {
"labeled": "Etiquetas de color"
}
}
},
"catalogedMedia": "{{size}} catalogados",
"storageUnavailable": "no disponible",
"catalogedMediaOnly": "catalogado — los objetos están en S3"
},
"permissions": {
"insufficient": "No tienes permiso para realizar esta acción",
+8 -2
View File
@@ -1243,7 +1243,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",
@@ -1867,7 +1870,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",
"storageUnavailable": "indisponible",
"catalogedMediaOnly": "catalogué — les objets sont dans S3"
},
"acceptInvitation": {
"title": "Accepter l'invitation",
+8 -2
View File
@@ -1221,7 +1221,10 @@
"totalPhotos": "Totaal foto's",
"activeEvents": "Actieve evenementen",
"notConfigured": "Umami Analytics niet geconfigureerd",
"configureInstructions": "Configureer Umami in uw omgevingsvariabelen en beheerdersinstellingen om echte statistieken te zien."
"configureInstructions": "Configureer Umami in uw omgevingsvariabelen en beheerdersinstellingen om echte statistieken te zien.",
"catalogedMedia": "Gecatalogiseerde media",
"storageUnavailable": "niet beschikbaar",
"storageNoMeasurement": "geen meting beschikbaar"
},
"branding": {
"title": "Huisstijl & Thema's",
@@ -1845,7 +1848,10 @@
"category_hero_updated": "Categorie hero-foto bijgewerkt",
"public_site_reset_to_default": "Publieke site teruggezet naar standaard",
"cms_page_logo_uploaded": "CMS-pagina-logo geüpload: {{slug}}"
}
},
"catalogedMedia": "{{size}} gecatalogiseerd",
"storageUnavailable": "niet beschikbaar",
"catalogedMediaOnly": "gecatalogiseerd — objecten staan in S3"
},
"acceptInvitation": {
"title": "Uitnodiging accepteren",
+8 -2
View File
@@ -1238,7 +1238,10 @@
"totalPhotos": "Total de Fotos",
"activeEvents": "Eventos Ativos",
"notConfigured": "Umami Analytics não configurado",
"configureInstructions": "Para ver dados reais, configure o Umami nas variáveis de ambiente e no painel admin."
"configureInstructions": "Para ver dados reais, configure o Umami nas variáveis de ambiente e no painel admin.",
"catalogedMedia": "Mídia catalogada",
"storageUnavailable": "indisponível",
"storageNoMeasurement": "sem medição disponível"
},
"branding": {
"title": "Marca e Temas",
@@ -1870,7 +1873,10 @@
"category_hero_updated": "Foto principal da categoria atualizada",
"public_site_reset_to_default": "Site público redefinido ao padrão",
"cms_page_logo_uploaded": "Logotipo da página CMS carregado: {{slug}}"
}
},
"catalogedMedia": "{{size}} catalogados",
"storageUnavailable": "indisponível",
"catalogedMediaOnly": "catalogado — os objetos estão no S3"
},
"acceptInvitation": {
"title": "Aceitar Convite",
+8 -2
View File
@@ -1255,7 +1255,10 @@
"totalPhotos": "Всего фото",
"activeEvents": "Активных событий",
"notConfigured": "Umami Analytics не настроен",
"configureInstructions": "Для отображения данных аналитики настройте Umami в переменных среды и настройках панели администратора."
"configureInstructions": "Для отображения данных аналитики настройте Umami в переменных среды и настройках панели администратора.",
"catalogedMedia": "Каталогизированные медиа",
"storageUnavailable": "недоступно",
"storageNoMeasurement": "измерение недоступно"
},
"branding": {
"title": "Брендинг и темы",
@@ -1895,7 +1898,10 @@
"category_hero_updated": "Главное фото категории обновлено",
"public_site_reset_to_default": "Публичный сайт сброшен к значениям по умолчанию",
"cms_page_logo_uploaded": "Логотип CMS-страницы загружен: {{slug}}"
}
},
"catalogedMedia": "{{size}} в каталоге",
"storageUnavailable": "недоступно",
"catalogedMediaOnly": "в каталоге — объекты хранятся в S3"
},
"acceptInvitation": {
"title": "Принять приглашение",
+8 -2
View File
@@ -1243,7 +1243,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",
@@ -1856,7 +1859,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",
"storageUnavailable": "ni na voljo",
"catalogedMediaOnly": "katalogizirano — objekti so v S3"
},
"acceptInvitation": {
"title": "Sprejmi povabilo",
+22 -1
View File
@@ -169,8 +169,29 @@ 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 — the one number an admin reaches for when asking
// "am I running out of disk" pointed away from the answer. `?? ` rather
// than `|| `: null means the measurement failed and must read as
// unavailable, not as 0 Bytes.
title: t('admin.storageUsed'),
value: adminService.formatBytes(dashboardStats?.storageUsed || 0),
// On an S3 backend there is no disk to measure, so the catalogued figure
// IS the answer available and stands in — labelled by the subtitle below
// rather than pretending a walk happened.
value: dashboardStats?.storageUsed == null
? (dashboardStats?.storageMeasurement === 'catalog'
? adminService.formatBytes(dashboardStats.catalogedBytes)
: t('admin.storageUnavailable', 'unavailable'))
: `${adminService.formatBytes(dashboardStats.storageUsed)}${dashboardStats.storagePartial ? '+' : ''}`,
// The catalogued figure alongside, so the difference is visible rather
// than conflated. On a managed install they track each other; on a
// reference one they are supposed to diverge.
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',
},
+43 -8
View File
@@ -438,15 +438,36 @@ export const AnalyticsPage: React.FC = () => {
{/* Storage Information */}
{dashboardStats && (() => {
// Real local bytes (#1164). This used to be the summed size of the
// catalogued originals, so a reference-mode install — where those
// files are on a NAS — compared a number from the NAS against a
// limit meant for this disk.
const localUsed = dashboardStats.storageUsed;
// 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 = localUsed ?? (dashboardStats.storageMeasurement === 'catalog'
? dashboardStats.catalogedBytes
: null);
const softLimitBytes = storageInfo?.storage_soft_limit ?? storageInfo?.storage_limit ?? storageInfo?.recommended_soft_limit ?? null;
// `measured`, not `localUsed`. An editor or viewer holds
// analytics.view but not settings.view, so /storage/info 403s and
// storageInfo is undefined — and on S3 localUsed 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.
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 +475,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 +491,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,14 +504,24 @@ 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}
</p>
</div>
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
{/* The catalogued size of the originals, shown separately
rather than as "used" (#1164). On a reference-mode
install this is large and none of it is on this disk,
which is the distinction the old single figure hid. */}
<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>
+31 -1
View File
@@ -61,7 +61,37 @@ export interface DashboardStats {
activeEvents: number;
expiringEvents: number;
totalPhotos: number;
storageUsed: number;
// Real bytes under the storage root — thumbnails, previews, hero
// renditions, download caches and any managed originals (#1164). Null when
// the measurement failed, which the UI must show 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: {
originals: number;
archives: number;
thumbnails: number;
previews: number;
heroes: number;
watermarks: number;
uploads: number;
businessDocs: number;
downloadCache: number;
externalMedia: number;
temp: number;
other: number;
} | null;
// The total is a floor: part of the storage root could not be read.
storagePartial?: boolean;
// Summed photos.size_bytes — the catalogued size of the originals, which is
// what this endpoint used to label "storage used". In reference mode those
// files are on external storage and none of those bytes are local.
catalogedBytes: number;
totalViews: number;
totalDownloads: number;
viewsTrend: number;
+13
View File
@@ -86,7 +86,20 @@ 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;
// Summed photos.size_bytes — what total_used used to be.
cataloged_bytes?: number;
// True when part of the storage root could not be read, so total_used is a
// floor rather than the answer. Anything comparing it against a limit has to
// say so, or an unreadable subtree reads as "safely under".
storage_partial?: boolean;
// Where total_used came from. 'disk' is the filesystem walk; 'catalog' means
// the backend is S3, where the objects are in the bucket and a walk of the
// local storage root would report near-zero.
storage_measurement?: 'disk' | 'catalog' | 'unavailable';
archive_storage: number;
storage_by_event: Array<{
event_name: string;