Add DiskSpaceService and DiskSpaceBar component for monitoring disk usage
Implement DiskSpaceService to provide snapshots of disk space usage, including total, free, and used bytes, as well as human-readable formats. Create DiskSpaceBar component to display disk usage information in the UI, including a progress bar that visually represents used space. Update app layout to include the DiskSpaceBar and add tests to ensure functionality of the disk space service and its integration in the layout.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Number;
|
||||
|
||||
class DiskSpaceService
|
||||
{
|
||||
/**
|
||||
* Snapshot of free/used space for the recordings storage volume.
|
||||
*
|
||||
* @return array{
|
||||
* total_bytes: int,
|
||||
* free_bytes: int,
|
||||
* used_bytes: int,
|
||||
* used_percent: float,
|
||||
* free_percent: float,
|
||||
* total_human: string,
|
||||
* free_human: string,
|
||||
* used_human: string
|
||||
* }|null
|
||||
*/
|
||||
public function snapshot(?string $path = null): ?array
|
||||
{
|
||||
$path ??= Storage::disk('local')->path('');
|
||||
|
||||
/** @var array{total_bytes: int, free_bytes: int, used_bytes: int, used_percent: float, free_percent: float, total_human: string, free_human: string, used_human: string}|null */
|
||||
return Cache::remember(
|
||||
'disk-space:'.md5($path),
|
||||
now()->addSeconds(30),
|
||||
fn () => $this->measure($path),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* total_bytes: int,
|
||||
* free_bytes: int,
|
||||
* used_bytes: int,
|
||||
* used_percent: float,
|
||||
* free_percent: float,
|
||||
* total_human: string,
|
||||
* free_human: string,
|
||||
* used_human: string
|
||||
* }|null
|
||||
*/
|
||||
private function measure(string $path): ?array
|
||||
{
|
||||
$total = @disk_total_space($path);
|
||||
$free = @disk_free_space($path);
|
||||
|
||||
if ($total === false || $free === false || $total <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$totalBytes = (int) $total;
|
||||
$freeBytes = (int) max(0, $free);
|
||||
$usedBytes = (int) max(0, $totalBytes - $freeBytes);
|
||||
$usedPercent = round(($usedBytes / $totalBytes) * 100, 1);
|
||||
$freePercent = round(($freeBytes / $totalBytes) * 100, 1);
|
||||
|
||||
return [
|
||||
'total_bytes' => $totalBytes,
|
||||
'free_bytes' => $freeBytes,
|
||||
'used_bytes' => $usedBytes,
|
||||
'used_percent' => $usedPercent,
|
||||
'free_percent' => $freePercent,
|
||||
'total_human' => Number::fileSize($totalBytes, precision: 1),
|
||||
'free_human' => Number::fileSize($freeBytes, precision: 1),
|
||||
'used_human' => Number::fileSize($usedBytes, precision: 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use App\Services\DiskSpaceService;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
class DiskSpaceBar extends Component
|
||||
{
|
||||
/**
|
||||
* @var array{
|
||||
* total_bytes: int,
|
||||
* free_bytes: int,
|
||||
* used_bytes: int,
|
||||
* used_percent: float,
|
||||
* free_percent: float,
|
||||
* total_human: string,
|
||||
* free_human: string,
|
||||
* used_human: string
|
||||
* }|null
|
||||
*/
|
||||
public readonly ?array $disk;
|
||||
|
||||
public function __construct(DiskSpaceService $diskSpace)
|
||||
{
|
||||
$this->disk = $diskSpace->snapshot();
|
||||
}
|
||||
|
||||
public function shouldRender(): bool
|
||||
{
|
||||
return $this->disk !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress fill color based on remaining free space.
|
||||
*/
|
||||
public function barColor(): string
|
||||
{
|
||||
$freePercent = $this->disk['free_percent'] ?? 100;
|
||||
|
||||
if ($freePercent <= 5) {
|
||||
return 'bg-red-600';
|
||||
}
|
||||
|
||||
if ($freePercent <= 15) {
|
||||
return 'bg-amber-500';
|
||||
}
|
||||
|
||||
return 'bg-teal-600';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the view / contents that represent the component.
|
||||
*/
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
return view('components.disk-space-bar');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
@php
|
||||
/** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */
|
||||
@endphp
|
||||
<div class="border-b border-stone-200 bg-white">
|
||||
<div class="mx-auto max-w-5xl px-4 py-2 sm:px-6">
|
||||
<div class="flex items-center justify-between gap-3 text-xs text-stone-600">
|
||||
<span class="font-medium text-stone-700">Disk space</span>
|
||||
<span class="tabular-nums">
|
||||
{{ $disk['free_human'] }} free
|
||||
<span class="text-stone-400">·</span>
|
||||
{{ $disk['used_percent'] }}% used of {{ $disk['total_human'] }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-stone-200"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="{{ (int) round($disk['used_percent']) }}"
|
||||
aria-label="Disk space used"
|
||||
title="{{ $disk['free_human'] }} free of {{ $disk['total_human'] }}"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-[width] duration-300 {{ $barColor }}"
|
||||
style="width: {{ min(100, max(0, $disk['used_percent'])) }}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7,6 +7,8 @@
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased">
|
||||
<x-disk-space-bar />
|
||||
|
||||
<header class="border-b border-stone-200 bg-white">
|
||||
<div class="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
|
||||
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800">
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Services\DiskSpaceService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DiskSpaceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_disk_space_service_returns_snapshot_for_storage_path(): void
|
||||
{
|
||||
Cache::flush();
|
||||
|
||||
$snapshot = app(DiskSpaceService::class)->snapshot();
|
||||
|
||||
$this->assertNotNull($snapshot);
|
||||
$this->assertGreaterThan(0, $snapshot['total_bytes']);
|
||||
$this->assertGreaterThanOrEqual(0, $snapshot['free_bytes']);
|
||||
$this->assertGreaterThanOrEqual(0, $snapshot['used_percent']);
|
||||
$this->assertLessThanOrEqual(100, $snapshot['used_percent']);
|
||||
$this->assertNotEmpty($snapshot['free_human']);
|
||||
$this->assertNotEmpty($snapshot['total_human']);
|
||||
}
|
||||
|
||||
public function test_disk_space_service_returns_null_for_missing_path(): void
|
||||
{
|
||||
Cache::flush();
|
||||
|
||||
$snapshot = app(DiskSpaceService::class)->snapshot('/path/that/does/not/exist-'.uniqid());
|
||||
|
||||
$this->assertNull($snapshot);
|
||||
}
|
||||
|
||||
public function test_layout_shows_disk_space_bar(): void
|
||||
{
|
||||
Cache::flush();
|
||||
Storage::fake('local');
|
||||
|
||||
$this->get(route('recordings.index'))
|
||||
->assertOk()
|
||||
->assertSee('Disk space')
|
||||
->assertSee('free')
|
||||
->assertSee('role="progressbar"', false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user