Report actual file storage and temporary upload capacity separately

This commit is contained in:
Jens
2026-09-20 17:58:08 +02:00
parent 60171799e7
commit 5902e7ab32
5 changed files with 133 additions and 16 deletions
@@ -30,6 +30,7 @@ use App\Modules\Platform\News\NewsItems;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Storage\StorageDurability;
use App\Modules\Platform\Storage\StorageCapacity;
use App\Modules\Platform\System\SystemEnvironment;
use App\Modules\Platform\Updates\LatestReleaseInfo;
use Illuminate\Database\Eloquent\Builder;
@@ -56,6 +57,7 @@ class DashboardController extends Controller
private readonly Settings $settings,
private readonly ApiUsage $apiUsage,
private readonly StorageDurability $storageDurability,
private readonly StorageCapacity $storageCapacity,
private readonly FileDelivery $fileDelivery,
private readonly Installation $installation,
private readonly TimezoneRegistry $timezones,
@@ -98,7 +100,7 @@ class DashboardController extends Controller
: null,
'largest_files' => $canStatistics && $prefs->isEnabled($user, 'largest_files') ? $this->largestFiles($user) : null,
'recent' => $canActionsLog && $prefs->isEnabled($user, 'recent') ? $this->recentActivity($user) : null,
'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo() : null,
'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo($user) : null,
// Both editions — informational content, not an update action,
// so no Capability check alongside the permission (unlike
// 'system' above).
@@ -485,10 +487,8 @@ class DashboardController extends Controller
/**
* @return array<string, array<string, bool|int|string|null>|bool|int|string|null>
*/
private function systemInfo(): array
private function systemInfo(User $viewer): array
{
$freeBytes = @disk_free_space(storage_path('app/files'));
// Cached by CheckForUpdatesCommand (daily) — never a live HTTP
// call from the request path. null means either no successful
// check yet, or the current version is already the latest.
@@ -497,7 +497,7 @@ class DashboardController extends Controller
return [
...$this->environment->toArray(),
'storage_used_bytes' => (int) File::query()->sum('size'),
'storage_free_bytes' => $freeBytes === false ? -1 : (int) $freeBytes,
...$this->storageCapacity->inspect($viewer),
'update_available' => $release !== null,
'latest_version' => $release['version'] ?? null,
'release_url' => $release['url'] ?? null,
@@ -353,6 +353,11 @@ class LocalPartStore
* deletes the whole tree, for everybody. Unset, which is every
* installation, the path is what it has always been.
*/
public function temporaryDirectory(): string
{
return $this->root();
}
private function root(): string
{
$configured = config('projectsend.uploads.parts_path');
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Storage;
use App\Models\User;
use App\Modules\Files\Storage\ResolvingUploadDisk;
use App\Modules\Files\Uploads\LocalPartStore;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage;
class StorageCapacity
{
public function __construct(private readonly LocalPartStore $parts) {}
/** @return array{storage_driver: string, storage_free_bytes: int, upload_temp_free_bytes: int} */
public function inspect(User $uploader): array
{
$event = new ResolvingUploadDisk($uploader);
Event::dispatch($event);
$driver = (string) config('filesystems.disks.'.$event->disk.'.driver');
return [
'storage_driver' => $driver,
// Object stores do not expose filesystem free space. Never report
// the VPS disk as the capacity of a remote storage provider.
'storage_free_bytes' => $driver === 'local'
? $this->freeBytes(Storage::disk($event->disk)->path(''))
: -1,
'upload_temp_free_bytes' => $this->freeBytes($this->parts->temporaryDirectory()),
];
}
protected function freeBytes(string $path): int
{
// Before the first upload, the directory may not exist yet. Its
// nearest existing ancestor is on the filesystem that will hold it.
while (! is_dir($path)) {
$parent = dirname($path);
if ($parent === $path) {
return -1;
}
$path = $parent;
}
$bytes = @disk_free_space($path);
return $bytes === false ? -1 : (int) $bytes;
}
}
@@ -3,8 +3,8 @@ import { Link, usePage } from '@inertiajs/react';
import { AlertTriangle, ArrowUpCircle, HardDrive, ShieldAlert } from 'lucide-react';
import { useState } from 'react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { FileDeliveryDialog, type FileDelivery } from '@/components/file-delivery-dialog';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { UpdateInstructions, type InstallKind } from '@/components/update-instructions';
import { useTranslation } from '@/hooks/use-translation';
import { formatBytes } from '@/lib/format-bytes';
@@ -23,6 +23,8 @@ export interface SystemInfo {
database: string;
storage_used_bytes: number;
storage_free_bytes: number;
storage_driver: string;
upload_temp_free_bytes: number;
update_available: boolean;
latest_version: string | null;
release_url: string | null;
@@ -109,7 +111,9 @@ function StorageDurabilityNotice({ durability }: { durability: StorageDurability
volume: durability.volume,
})
: t('They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.')}{' '}
{t('That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.')}
{t(
'That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.',
)}
</p>
</AlertDescription>
</Alert>
@@ -207,9 +211,7 @@ export function SystemWidget({ system, onViewReleaseNotes }: { system: SystemInf
<li>{t(':count files have been waiting to be checked for over an hour.', { count: system.scanning.pending })}</li>
)}
{system.scanning.definitions_age_hours !== null && system.scanning.definitions_age_hours >= 72 && (
<li>
{t('The virus definitions are :hours hours old.', { hours: system.scanning.definitions_age_hours })}
</li>
<li>{t('The virus definitions are :hours hours old.', { hours: system.scanning.definitions_age_hours })}</li>
)}
</ul>
<Link href="/system/settings/virus-scanning" className="mt-1 inline-block underline hover:no-underline">
@@ -268,12 +270,30 @@ export function SystemWidget({ system, onViewReleaseNotes }: { system: SystemInf
<dt className="text-muted-foreground">{t('Storage used')}</dt>
<dd>{formatBytes(system.storage_used_bytes)}</dd>
</div>
{system.storage_free_bytes >= 0 && (
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t('Storage free')}</dt>
<dd>{formatBytes(system.storage_free_bytes)}</dd>
</div>
)}
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t('File storage')}</dt>
<dd>
{system.storage_driver === 'local'
? t('Local disk')
: system.storage_driver === 's3'
? t('S3-compatible storage')
: t('External storage')}
</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t('Storage available')}</dt>
<dd>
{system.storage_driver !== 'local'
? t('Managed by provider')
: system.storage_free_bytes >= 0
? formatBytes(system.storage_free_bytes)
: t('Unknown')}
</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t('Temporary upload space')}</dt>
<dd>{system.upload_temp_free_bytes >= 0 ? formatBytes(system.upload_temp_free_bytes) : t('Unknown')}</dd>
</div>
{/* Stated always, flagged only when it is the slow one.
Both halves of the row open the explanation, and the
label is underlined, because the icon alone did not read
@@ -367,6 +387,10 @@ export function SystemWidget({ system, onViewReleaseNotes }: { system: SystemInf
</div>
)}
</dl>
<p className="text-muted-foreground text-xs">
{t('Uploads use temporary space while being assembled, including when files are stored externally.')}
</p>
<FileDeliveryDialog delivery={system.file_delivery} open={deliveryOpen} onOpenChange={setDeliveryOpen} />
</div>
);
+37
View File
@@ -590,3 +590,40 @@ test('the top-clients widget names only clients on the viewer roster', function
->where('top_clients_by_storage.0.name', 'My Own Client'),
);
});
test('system capacity distinguishes local file storage from a configured temporary upload volume', function () {
$tempRoot = storage_path('app/separate-upload-volume');
config(['projectsend.uploads.parts_path' => $tempRoot]);
$capacity = new class(app(App\Modules\Files\Uploads\LocalPartStore::class)) extends App\Modules\Platform\Storage\StorageCapacity
{
protected function freeBytes(string $path): int
{
return $path === config('projectsend.uploads.parts_path') ? 99_000_000_000 : 39_000_000_000;
}
};
app()->instance(App\Modules\Platform\Storage\StorageCapacity::class, $capacity);
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page
->where('system.storage_driver', 'local')
->where('system.storage_free_bytes', 39_000_000_000)
->where('system.upload_temp_free_bytes', 99_000_000_000),
);
});
test('system capacity never presents local disk space as available object storage', function () {
config(['filesystems.disks.remote_uploads.driver' => 's3']);
Illuminate\Support\Facades\Event::listen(
App\Modules\Files\Storage\ResolvingUploadDisk::class,
function (App\Modules\Files\Storage\ResolvingUploadDisk $event): void {
$event->disk = 'remote_uploads';
},
);
// No credentials or bucket: reading capacity must not contact S3.
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page
->where('system.storage_driver', 's3')
->where('system.storage_free_bytes', -1)
->where('system.upload_temp_free_bytes', fn ($bytes): bool => is_int($bytes) && $bytes >= 0),
);
});