Skip to content

Commit

Permalink
feat: Added statistics page
Browse files Browse the repository at this point in the history
  • Loading branch information
lewislarsen committed Jul 27, 2024
1 parent 308d378 commit d08fcb4
Show file tree
Hide file tree
Showing 7 changed files with 783 additions and 48 deletions.
235 changes: 235 additions & 0 deletions app/Livewire/StatisticsPage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
<?php

declare(strict_types=1);

namespace App\Livewire;

use App\Models\BackupDestination;
use App\Models\BackupTask;
use App\Models\BackupTaskData;
use App\Models\BackupTaskLog;
use App\Models\RemoteServer;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Number;
use Illuminate\View\View;
use Livewire\Component;

/**
* StatisticsPage Livewire Component
*
* This component handles the statistics page, loading and preparing various
* backup-related statistics for display.
*/
class StatisticsPage extends Component
{
/** @var array<string> Dates for the backup tasks chart */
public array $backupDates = [];

/** @var array<int> Counts of file backups for each date */
public array $fileBackupCounts = [];

/** @var array<int> Counts of database backups for each date */
public array $databaseBackupCounts = [];

/** @var array<string> Labels for the backup success rate chart */
public array $backupSuccessRateLabels = [];

/** @var array<float> Data for the backup success rate chart */
public array $backupSuccessRateData = [];

/** @var array<string> Labels for the average backup size chart */
public array $averageBackupSizeLabels = [];

/** @var array<float> Data for the average backup size chart */
public array $averageBackupSizeData = [];

/** @var array<string> Labels for the completion time chart */
public array $completionTimeLabels = [];

/** @var array<float> 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);
}
}
103 changes: 55 additions & 48 deletions app/Services/Backup/Backup.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,51 +173,33 @@ 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
{
$this->logInfo('Getting remote database size.', ['database_type' => $databaseType, 'database' => $databaseName]);

$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));
}

/**
Expand Down Expand Up @@ -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;
}
}
8 changes: 8 additions & 0 deletions resources/views/livewire/layout/navigation.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ public function toggleUserDropdown(): void
@svg('heroicon-o-bell', 'h-5 w-5 mr-2 inline')
{{ __('Notifications') }}
</x-dropdown-link>
<x-dropdown-link :href="route('statistics')" wire:navigate>
@svg('heroicon-o-chart-pie', 'h-5 w-5 mr-2 inline')
{{ __('Statistics') }}
</x-dropdown-link>
@if (Auth::user()->isAdmin())
<div class="border-t border-gray-200 dark:border-gray-600"></div>
<x-dropdown-link href="{{ url('/pulse') }}">
Expand Down Expand Up @@ -180,6 +184,10 @@ public function toggleUserDropdown(): void
@svg('heroicon-o-bell', 'h-5 w-5 text-gray-50 mr-2 inline')
{{ __('Notifications') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('statistics')" wire:navigate>
@svg('heroicon-o-chart-pie', 'h-5 w-5 text-gray-50 mr-2 inline')
{{ __('Statistics') }}
</x-responsive-nav-link>
@if (Auth::user()->isAdmin())
<x-responsive-nav-link href="{{ url('/pulse') }}">
@svg('heroicon-o-chart-bar', 'h-5 w-5 text-gray-50 mr-2 inline')
Expand Down
Loading

0 comments on commit d08fcb4

Please sign in to comment.