diff --git a/app/Livewire/StatisticsPage.php b/app/Livewire/StatisticsPage.php new file mode 100644 index 00000000..bfabe59a --- /dev/null +++ b/app/Livewire/StatisticsPage.php @@ -0,0 +1,235 @@ + Dates for the backup tasks chart */ + public array $backupDates = []; + + /** @var array Counts of file backups for each date */ + public array $fileBackupCounts = []; + + /** @var array Counts of database backups for each date */ + public array $databaseBackupCounts = []; + + /** @var array Labels for the backup success rate chart */ + public array $backupSuccessRateLabels = []; + + /** @var array Data for the backup success rate chart */ + public array $backupSuccessRateData = []; + + /** @var array Labels for the average backup size chart */ + public array $averageBackupSizeLabels = []; + + /** @var array Data for the average backup size chart */ + public array $averageBackupSizeData = []; + + /** @var array Labels for the completion time chart */ + public array $completionTimeLabels = []; + + /** @var array Data for the completion time chart */ + public array $completionTimeData = []; + + /** @var string Total data backed up in the past seven days */ + public string $dataBackedUpInThePastSevenDays; + + /** @var string Total data backed up in the past month */ + public string $dataBackedUpInThePastMonth; + + /** @var string Total data backed up overall */ + public string $dataBackedUpInTotal; + + /** @var int Number of linked servers */ + public int $linkedServers; + + /** @var int Number of linked backup destinations */ + public int $linkedBackupDestinations; + + /** @var int Number of active backup tasks */ + public int $activeBackupTasks; + + /** @var int Number of paused backup tasks */ + public int $pausedBackupTasks; + + /** + * Initialize the component state + */ + public function mount(): void + { + $this->loadBackupTasksData(); + $this->loadStatistics(); + $this->loadAverageBackupSizeData(); + $this->loadBackupSuccessRateData(); + $this->loadCompletionTimeData(); + } + + /** + * Render the component + */ + public function render(): View + { + return view('livewire.statistics-page'); + } + + /** + * Load backup tasks data for the past 90 days + */ + private function loadBackupTasksData(): void + { + $startDate = now()->subDays(89); + $endDate = now(); + + $backupTasks = BackupTask::selectRaw('DATE(created_at) as date, type, COUNT(*) as count') + ->whereBetween('created_at', [$startDate, $endDate]) + ->groupBy('date', 'type') + ->orderBy('date') + ->get(); + + $dates = Collection::make($startDate->daysUntil($endDate)->toArray()) + ->map(fn (CarbonInterface $date, $key): string => $date->format('Y-m-d')); + + $fileBackups = $databaseBackups = array_fill_keys($dates->toArray(), 0); + + foreach ($backupTasks as $backupTask) { + $date = $backupTask['date']; + $count = (int) $backupTask['count']; + if ($backupTask['type'] === 'Files') { + $fileBackups[$date] = $count; + } else { + $databaseBackups[$date] = $count; + } + } + + $this->backupDates = $dates->values()->toArray(); + $this->fileBackupCounts = array_values($fileBackups); + $this->databaseBackupCounts = array_values($databaseBackups); + } + + /** + * Load general statistics + */ + private function loadStatistics(): void + { + $this->dataBackedUpInThePastSevenDays = $this->formatFileSize( + (int) BackupTaskData::where('created_at', '>=', now()->subDays(7))->sum('size') + ); + + $this->dataBackedUpInThePastMonth = $this->formatFileSize( + (int) BackupTaskData::where('created_at', '>=', now()->subMonth())->sum('size') + ); + + $this->dataBackedUpInTotal = $this->formatFileSize( + (int) BackupTaskData::sum('size') + ); + + $this->linkedServers = RemoteServer::whereUserId(Auth::id())->count(); + $this->linkedBackupDestinations = BackupDestination::whereUserId(Auth::id())->count(); + $this->activeBackupTasks = BackupTask::whereUserId(Auth::id())->whereNull('paused_at')->count(); + $this->pausedBackupTasks = BackupTask::whereUserId(Auth::id())->whereNotNull('paused_at')->count(); + } + + /** + * Load backup success rate data for the past 6 months + */ + private function loadBackupSuccessRateData(): void + { + $startDate = now()->startOfMonth()->subMonths(5); + $endDate = now()->endOfMonth(); + + $backupLogs = BackupTaskLog::selectRaw("DATE_TRUNC('month', created_at)::date as month") + ->selectRaw('COUNT(*) as total') + ->selectRaw('SUM(CASE WHEN successful_at IS NOT NULL THEN 1 ELSE 0 END) as successful') + ->whereBetween('created_at', [$startDate, $endDate]) + ->groupBy('month') + ->orderBy('month') + ->get(); + + $this->backupSuccessRateLabels = $backupLogs->pluck('month')->map(fn ($date): string => Carbon::parse($date)->format('Y-m'))->toArray(); + $this->backupSuccessRateData = $backupLogs->map(function ($log): float|int { + $total = (int) ($log['total'] ?? 0); + $successful = (int) ($log['successful'] ?? 0); + + return $total > 0 ? round(($successful / $total) * 100, 2) : 0; + })->toArray(); + } + + /** + * Load average backup size data + */ + private function loadAverageBackupSizeData(): void + { + $backupSizes = BackupTask::join('backup_task_data', 'backup_tasks.id', '=', 'backup_task_data.backup_task_id') + ->join('backup_task_logs', 'backup_tasks.id', '=', 'backup_task_logs.backup_task_id') + ->select('backup_tasks.type') + ->selectRaw('AVG(backup_task_data.size) as average_size') + ->whereNotNull('backup_task_logs.successful_at') + ->groupBy('backup_tasks.type') + ->get(); + + $this->averageBackupSizeLabels = $backupSizes->pluck('type')->toArray(); + $this->averageBackupSizeData = $backupSizes->pluck('average_size') + ->map(fn ($size): string => $this->formatFileSize((int) $size)) + ->toArray(); + } + + private function loadCompletionTimeData(): void + { + $startDate = now()->subMonths(3); + $endDate = now(); + + $completionTimes = BackupTaskData::join('backup_task_logs', 'backup_task_data.backup_task_id', '=', 'backup_task_logs.backup_task_id') + ->selectRaw('DATE(backup_task_logs.created_at) as date') + ->selectRaw(" + AVG( + CASE + WHEN backup_task_data.duration ~ '^\\d+$' THEN backup_task_data.duration::integer + WHEN backup_task_data.duration ~ '^(\\d+):(\\d+):(\\d+)$' THEN + (SUBSTRING(backup_task_data.duration FROM '^(\\d+)'))::integer * 3600 + + (SUBSTRING(backup_task_data.duration FROM '^\\d+:(\\d+)'))::integer * 60 + + (SUBSTRING(backup_task_data.duration FROM ':(\\d+)$'))::integer + ELSE 0 + END + ) as avg_duration + ") + ->whereBetween('backup_task_logs.created_at', [$startDate, $endDate]) + ->whereNotNull('backup_task_logs.successful_at') + ->groupBy('date') + ->orderBy('date') + ->get(); + + $this->completionTimeLabels = $completionTimes->pluck('date')->toArray(); + $this->completionTimeData = $completionTimes->pluck('avg_duration') + ->map(fn ($duration): float => round($duration / 60, 2)) + ->toArray(); + } + + /** + * Format file size using the Number facade + */ + private function formatFileSize(int $bytes): string + { + return Number::fileSize($bytes); + } +} diff --git a/app/Services/Backup/Backup.php b/app/Services/Backup/Backup.php index 9d66d551..17e38314 100644 --- a/app/Services/Backup/Backup.php +++ b/app/Services/Backup/Backup.php @@ -173,7 +173,15 @@ public function getRemoteDirectorySize(SFTPInterface $sftp, string $path): int } /** - * @throws SFTPConnectionException + * Get the size of a remote database in bytes. + * + * @param SFTPInterface $sftp SFTP connection interface + * @param string $databaseType Type of the database (mysql or postgresql) + * @param string $databaseName Name of the database + * @param string $password Database password + * @return int Size of the database in bytes + * + * @throws SFTPConnectionException If there's an error in connection or unsupported database type */ public function getRemoteDatabaseSize(SFTPInterface $sftp, string $databaseType, string $databaseName, string $password): int { @@ -181,43 +189,17 @@ public function getRemoteDatabaseSize(SFTPInterface $sftp, string $databaseType, $this->validateSFTP($sftp); - if ($databaseType === BackupConstants::DATABASE_TYPE_MYSQL) { - $sizeCommand = sprintf( - 'mysql -p%s -e "SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 1) AS size_mb FROM information_schema.tables WHERE table_schema = \'%s\';"', - escapeshellarg($password), - escapeshellarg($databaseName) - ); - - } elseif ($databaseType === BackupConstants::DATABASE_TYPE_POSTGRESQL) { - $sizeCommand = sprintf( - 'PGPASSWORD=%s psql -d %s -c "SELECT pg_size_pretty(pg_database_size(\'%s\')) AS size;" -t | awk \'{print $1}\'', - escapeshellarg($password), - escapeshellarg($databaseName), - escapeshellarg($databaseName) - ); - - } else { - $this->logError('Unsupported database type.', ['database_type' => $databaseType]); - throw new SFTPConnectionException('Unsupported database type.'); - } + $sizeCommand = $this->buildSizeCommand($databaseType, $databaseName, $password); $output = $sftp->exec($sizeCommand); $this->logDebug('Database size command output.', ['command' => $sizeCommand, 'output' => $output]); - if (is_string($output)) { - $size = trim($output); - if ($databaseType === BackupConstants::DATABASE_TYPE_MYSQL) { - $sizeInMb = (float) $size; - } elseif ($databaseType === BackupConstants::DATABASE_TYPE_POSTGRESQL) { - $sizeInMb = $this->convertPgSizeToMb($size); - } - - return (int) $sizeInMb; + if (! is_string($output) || (trim($output) === '' || trim($output) === '0')) { + $this->logError('Failed to get the database size.'); + throw new SFTPConnectionException('Failed to retrieve database size.'); } - $this->logError('Failed to get the database size.'); - - return 0; + return $this->parseSizeOutput(trim($output)); } /** @@ -634,26 +616,51 @@ protected function handleException(Exception $exception, string $context): void $this->logError($context . ': ' . $exception->getMessage(), ['exception' => $exception]); } - private function convertPgSizeToMb(string $size): float + /** + * Build the appropriate size command based on database type. + * + * @param string $databaseType Type of the database + * @param string $databaseName Name of the database + * @param string $password Database password + * @return string The command to execute + * + * @throws SFTPConnectionException If the database type is unsupported + */ + private function buildSizeCommand(string $databaseType, string $databaseName, string $password): string { - $units = [ - 'mb' => 1, - 'kb' => 1 / 1024, - 'gb' => 1024, - 'tb' => 1024 * 1024, - ]; - - $size = strtolower($size); - - preg_match('/([\d.]+)\s*([a-z]+)/', $size, $matches); + return match ($databaseType) { + BackupConstants::DATABASE_TYPE_MYSQL => sprintf( + "mysql -p%s -e \"SELECT SUM(data_length + index_length) FROM information_schema.tables WHERE table_schema = '%s';\"", + escapeshellarg($password), + escapeshellarg($databaseName) + ), + BackupConstants::DATABASE_TYPE_POSTGRESQL => sprintf( + "PGPASSWORD=%s psql -d %s -c \"SELECT pg_database_size('%s');\" -t", + escapeshellarg($password), + escapeshellarg($databaseName), + escapeshellarg($databaseName) + ), + default => throw new SFTPConnectionException('Unsupported database type: ' . $databaseType), + }; + } - if (isset($matches[1]) && isset($matches[2])) { - $number = (float) $matches[1]; - $unit = $matches[2]; + /** + * Parse the size output based on database type. + * + * @param string $output Command output + * @return int Size in bytes + * + * @throws SFTPConnectionException If parsing fails + */ + private function parseSizeOutput(string $output): int + { + $size = filter_var($output, FILTER_SANITIZE_NUMBER_INT); - return isset($units[$unit]) ? $number * $units[$unit] : $number / (1024 * 1024); // Assume bytes if unit is not recognized + if ($size === false || $size === null) { + $this->logError('Failed to parse database size output.', ['output' => $output]); + throw new SFTPConnectionException('Failed to parse database size output.'); } - return (float) $size / (1024 * 1024); + return (int) $size; } } diff --git a/resources/views/livewire/layout/navigation.blade.php b/resources/views/livewire/layout/navigation.blade.php index 72fa6669..ffaf540d 100644 --- a/resources/views/livewire/layout/navigation.blade.php +++ b/resources/views/livewire/layout/navigation.blade.php @@ -106,6 +106,10 @@ public function toggleUserDropdown(): void @svg('heroicon-o-bell', 'h-5 w-5 mr-2 inline') {{ __('Notifications') }} + + @svg('heroicon-o-chart-pie', 'h-5 w-5 mr-2 inline') + {{ __('Statistics') }} + @if (Auth::user()->isAdmin())
@@ -180,6 +184,10 @@ public function toggleUserDropdown(): void @svg('heroicon-o-bell', 'h-5 w-5 text-gray-50 mr-2 inline') {{ __('Notifications') }} + + @svg('heroicon-o-chart-pie', 'h-5 w-5 text-gray-50 mr-2 inline') + {{ __('Statistics') }} + @if (Auth::user()->isAdmin()) @svg('heroicon-o-chart-bar', 'h-5 w-5 text-gray-50 mr-2 inline') diff --git a/resources/views/livewire/statistics-page.blade.php b/resources/views/livewire/statistics-page.blade.php new file mode 100644 index 00000000..114d62f2 --- /dev/null +++ b/resources/views/livewire/statistics-page.blade.php @@ -0,0 +1,449 @@ +
+ @section('title', __('Statistics')) + + {{ __('Statistics') }} + +
+
+ @if (Auth::user()->backupTasks->count() === 0) + + + @svg('heroicon-o-chart-pie', 'h-16 w-16 text-primary-900 dark:text-white inline') + + + {{ __('No Data Available') }} + + + {{ __('Backup Tasks are required to be ran in order to generate statistical data.') }} + + + @else +
+
+
+
+
+ @svg('heroicon-o-server', ['class' => 'h-6 w-6 text-primary-600 dark:text-primary-400']) +
+
+

{{ __('Backup Data Statistics') }}

+

{{ __('Data backed up over different periods') }}

+
+
+
+
+

{{ __('Last 7 days') }}: {{ $dataBackedUpInThePastSevenDays }}

+

{{ __('Last month') }}: {{ $dataBackedUpInThePastMonth }}

+

{{ __('Total') }}: {{ $dataBackedUpInTotal }}

+
+
+ +
+
+
+
+ @svg('heroicon-o-link', ['class' => 'h-6 w-6 text-primary-600 dark:text-primary-400']) +
+
+

{{ __('Linked Resources') }}

+

{{ __('Connected servers and destinations') }}

+
+
+
+
+

{{ __('Remote Servers') }}: {{ $linkedServers }}

+

{{ __('Backup Destinations') }}: {{ $linkedBackupDestinations }}

+
+
+ +
+
+
+
+ @svg('heroicon-o-clipboard-document-list', ['class' => 'h-6 w-6 text-primary-600 dark:text-primary-400']) +
+
+

{{ __('Backup Tasks') }}

+

{{ __('Status of your backup tasks') }}

+
+
+
+
+

{{ __('Active') }}: {{ $activeBackupTasks }}

+

{{ __('Paused') }}: {{ $pausedBackupTasks }}

+
+
+
+ +
+
+
+
+
+ @svg('heroicon-o-chart-bar', ['class' => 'h-6 w-6 text-primary-600 dark:text-primary-400']) +
+
+

+ {{ __('Backups over the past 90 days') }} +

+

+ {{ __('All backups over the past ninety days.') }} +

+
+
+
+
+ +
+
+ +
+
+
+
+ @svg('heroicon-o-chart-bar', ['class' => 'h-6 w-6 text-primary-600 dark:text-primary-400']) +
+
+

+ {{ __('Backup Success Rate') }} +

+

+ {{ __('Success rate of backups over the last 6 months') }} +

+
+
+
+
+ +
+
+ +
+
+
+
+ @svg('heroicon-o-chart-bar', ['class' => 'h-6 w-6 text-primary-600 dark:text-primary-400']) +
+
+

+ {{ __('Average Backup Size by Type') }} +

+

+ {{ __('Average size of backups for each type') }} +

+
+
+
+
+ +
+
+ +
+
+
+
+ @svg('heroicon-o-chart-bar', ['class' => 'h-6 w-6 text-primary-600 dark:text-primary-400']) +
+
+

+ {{ __('Backup Task Completion Time Trend') }} +

+

+ {{ __('Average completion time of backup tasks over the last 3 months') }} +

+
+
+
+
+ +
+
+
+ @endif +
+
+
diff --git a/routes/breadcrumbs.php b/routes/breadcrumbs.php index fa322e54..fe53fa90 100644 --- a/routes/breadcrumbs.php +++ b/routes/breadcrumbs.php @@ -90,3 +90,7 @@ $trail->parent('notification-streams.index'); $trail->push(__('Update Notification Stream'), route('notification-streams.edit', $tag)); }); + +Breadcrumbs::for('statistics', function (BreadcrumbTrail $trail) { + $trail->push(__('Statistics'), route('statistics')); +}); diff --git a/routes/web.php b/routes/web.php index e7afe04f..735e588c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Livewire\NotificationStreams\Forms\CreateNotificationStream; use App\Livewire\NotificationStreams\Forms\UpdateNotificationStream; use App\Livewire\NotificationStreams\Index as NotificationStreamIndex; +use App\Livewire\StatisticsPage; use Illuminate\Support\Facades\Route; Route::redirect('/', '/overview'); @@ -59,6 +60,8 @@ Route::get('edit/{notificationStream}', UpdateNotificationStream::class)->name('notification-streams.edit') ->middleware('can:update,notificationStream'); }); + + Route::get('statistics', StatisticsPage::class)->name('statistics'); }); require __DIR__ . '/auth.php'; diff --git a/tests/Feature/Livewire/StatisticsPageTest.php b/tests/Feature/Livewire/StatisticsPageTest.php new file mode 100644 index 00000000..cf019d9b --- /dev/null +++ b/tests/Feature/Livewire/StatisticsPageTest.php @@ -0,0 +1,29 @@ +create(); + $component = Livewire::actingAs($user)->test(StatisticsPage::class); + $component->assertOk(); +}); + +test('the page can be loaded', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->get(route('statistics')); + + $response->assertOk(); +}); + +test('guests cannot access this page', function (): void { + $response = $this->get(route('statistics')); + + $response->assertRedirect(route('login')); + + $this->assertGuest(); +});