diff --git a/README.md b/README.md index 05b08a1..2cd216d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,41 @@ + + +![filament-banner](./docs/filament-banner.jpg) + # Filament Job Manager Filament panel for managing job queues including failed jobs and batches. +## Features + +### Jobs + +Monitor your running and finished jobs: + +![screenshot-jobs](./docs/screenshot-jobs.jpg) + +This table includes auto-pruning (7 days retention, configurable). + +### Jobs waiting + +See all waiting Jobs queued, kill one or many: + +![screenshot-waiting](./docs/screenshot-waiting.jpg) + +### Jobs failed + +See all failed Jobs including details, retry or delete: + +![screenshot-details](./docs/screenshot-details.jpg) + +![screenshot-detail](./docs/screenshot-detail.jpg) + +### Job batches + +See your job batches, prune batches: + +![screenshot-batches](./docs/screenshot-batches.jpg) + ## Installation This Laravel package is made for Filament 3 and the awesome TALL-Stack. @@ -34,8 +68,6 @@ php artisan vendor:publish --tag="filament-job-manager-config" This is the content of the published config file: ```php - [ 'jobs' => [ @@ -43,18 +75,28 @@ return [ 'label' => 'Job', 'plural_label' => 'Jobs', 'navigation_group' => 'Job manager', - 'navigation_icon' => 'heroicon-o-cpu-chip', + 'navigation_icon' => 'heroicon-o-play', 'navigation_sort' => 1, 'navigation_count_badge' => true, 'resource' => Adrolli\FilamentJobManager\Resources\JobsResource::class, ], + 'jobs_waiting' => [ + 'enabled' => true, + 'label' => 'Job waiting', + 'plural_label' => 'Jobs waiting', + 'navigation_group' => 'Job manager', + 'navigation_icon' => 'heroicon-o-pause', + 'navigation_sort' => 2, + 'navigation_count_badge' => true, + 'resource' => Adrolli\FilamentJobManager\Resources\WaitingJobsResource::class, + ], 'failed_jobs' => [ 'enabled' => true, 'label' => 'Failed Job', 'plural_label' => 'Failed Jobs', 'navigation_group' => 'Job manager', - 'navigation_icon' => 'heroicon-o-exclamation-circle', - 'navigation_sort' => 2, + 'navigation_icon' => 'heroicon-o-exclamation-triangle', + 'navigation_sort' => 3, 'navigation_count_badge' => true, 'resource' => Adrolli\FilamentJobManager\Resources\FailedJobsResource::class, ], @@ -64,7 +106,7 @@ return [ 'plural_label' => 'Job Batches', 'navigation_group' => 'Job manager', 'navigation_icon' => 'heroicon-o-inbox-stack', - 'navigation_sort' => 3, + 'navigation_sort' => 4, 'navigation_count_badge' => true, 'resource' => Adrolli\FilamentJobManager\Resources\JobBatchesResource::class, ], @@ -74,14 +116,14 @@ return [ 'retention_days' => 7, ], ]; - ``` Register the Plugins in `app/Providers/Filament/AdminPanelProvider.php`: ```php ->plugins([ - FilamentJobManagerPlugin::make(), + FilamentJobsPlugin::make(), + FilamentWaitingJobsPlugin::make(), FilamentFailedJobsPlugin::make(), FilamentJobBatchesPlugin::make(), ]) @@ -91,7 +133,7 @@ Instead of publishing and modifying the config-file, you can also do all setting ```php ->plugins([ - FilamentJobManagerPlugin::make() + FilamentJobsPlugin::make() ->label('Job runs') ->pluralLabel('Jobs that seems to run') ->enableNavigation(true) @@ -101,6 +143,14 @@ Instead of publishing and modifying the config-file, you can also do all setting ->navigationCountBadge(true) ->enablePruning(true) ->pruningRetention(7), + FilamentWaitingJobsPlugin::make() + ->label('Job waiting') + ->pluralLabel('Jobs waiting in line') + ->enableNavigation(true) + ->navigationIcon('heroicon-o-calendar') + ->navigationGroup('My Jobs and Queues') + ->navigationSort(5) + ->navigationCountBadge(true) FilamentFailedJobsPlugin::make() ->label('Job failed') ->pluralLabel('Jobs that failed hard') @@ -116,7 +166,12 @@ You don't need to register all Resources. If you don't use Job Batches, you can ## Usage -Just run a Background Job and go to the route `/admin/jobs` to see the jobs. +Start your queue with `php artisan queue:work`, run a Background Job (use following example, if you need one) and go to the route + +- `/admin/jobs` to see the jobs running and done +- `/admin/waiting-jobs` to see or delete waiting jobs +- `/admin/failed-jobs` to see, retry or delete failed jobs +- `/admin/job-batches` to see job batches, or prune the batch table ## Example Job @@ -209,12 +264,18 @@ Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed re The MIT License (MIT). Please see [License File](LICENSE.md) for more information. +## Sponsors + +The initial development of this plugin was sponsored by [heco gmbh, Germany](https://heco.de). A huge thank you for investing in Open Source! + +If you use this plugin, please consider a small donation to keep this project under maintenance. Especially if it is a commercial project, it is pretty easy to calculate. A few bucks for a developer to build a great product or a hungry developer that produces bugs or - the worst case - needs to abandon the project. Yes, I am happy about every little sunshine in my wallet ;-) + ## Credits This Filament Plugin is heavily inspired (uses concept and / or code) from: -- https://github.com/croustibat/filament-jobs-monitor -- https://gitlab.com/amvisor/filament-failed-jobs +- https://github.com/croustibat/filament-jobs-monitor +- https://gitlab.com/amvisor/filament-failed-jobs Both under MIT License. A BIG thank you!!! diff --git a/config/filament-job-manager.php b/config/filament-job-manager.php index 291bd93..1313542 100644 --- a/config/filament-job-manager.php +++ b/config/filament-job-manager.php @@ -7,18 +7,28 @@ 'label' => 'Job', 'plural_label' => 'Jobs', 'navigation_group' => 'Job manager', - 'navigation_icon' => 'heroicon-o-cpu-chip', + 'navigation_icon' => 'heroicon-o-play', 'navigation_sort' => 1, 'navigation_count_badge' => true, 'resource' => Adrolli\FilamentJobManager\Resources\JobsResource::class, ], + 'jobs_waiting' => [ + 'enabled' => true, + 'label' => 'Job waiting', + 'plural_label' => 'Jobs waiting', + 'navigation_group' => 'Job manager', + 'navigation_icon' => 'heroicon-o-pause', + 'navigation_sort' => 2, + 'navigation_count_badge' => true, + 'resource' => Adrolli\FilamentJobManager\Resources\WaitingJobsResource::class, + ], 'failed_jobs' => [ 'enabled' => true, 'label' => 'Failed Job', 'plural_label' => 'Failed Jobs', 'navigation_group' => 'Job manager', - 'navigation_icon' => 'heroicon-o-exclamation-circle', - 'navigation_sort' => 2, + 'navigation_icon' => 'heroicon-o-exclamation-triangle', + 'navigation_sort' => 3, 'navigation_count_badge' => true, 'resource' => Adrolli\FilamentJobManager\Resources\FailedJobsResource::class, ], @@ -28,7 +38,7 @@ 'plural_label' => 'Job Batches', 'navigation_group' => 'Job manager', 'navigation_icon' => 'heroicon-o-inbox-stack', - 'navigation_sort' => 3, + 'navigation_sort' => 4, 'navigation_count_badge' => true, 'resource' => Adrolli\FilamentJobManager\Resources\JobBatchesResource::class, ], diff --git a/docs/filament-banner.jpg b/docs/filament-banner.jpg new file mode 100644 index 0000000..02d0604 Binary files /dev/null and b/docs/filament-banner.jpg differ diff --git a/docs/screenshot-batches.jpg b/docs/screenshot-batches.jpg new file mode 100644 index 0000000..ea8c0a9 Binary files /dev/null and b/docs/screenshot-batches.jpg differ diff --git a/docs/screenshot-detail.jpg b/docs/screenshot-detail.jpg new file mode 100644 index 0000000..015fb25 Binary files /dev/null and b/docs/screenshot-detail.jpg differ diff --git a/docs/screenshot-details.jpg b/docs/screenshot-details.jpg new file mode 100644 index 0000000..8f72af4 Binary files /dev/null and b/docs/screenshot-details.jpg differ diff --git a/docs/screenshot-failed.jpg b/docs/screenshot-failed.jpg new file mode 100644 index 0000000..563b315 Binary files /dev/null and b/docs/screenshot-failed.jpg differ diff --git a/docs/screenshot-jobs.jpg b/docs/screenshot-jobs.jpg new file mode 100644 index 0000000..5d787a4 Binary files /dev/null and b/docs/screenshot-jobs.jpg differ diff --git a/docs/screenshot-light.jpg b/docs/screenshot-light.jpg new file mode 100644 index 0000000..bcd0a3a Binary files /dev/null and b/docs/screenshot-light.jpg differ diff --git a/docs/screenshot-retry.jpg b/docs/screenshot-retry.jpg new file mode 100644 index 0000000..d2d7ed6 Binary files /dev/null and b/docs/screenshot-retry.jpg differ diff --git a/docs/screenshot-waiting.jpg b/docs/screenshot-waiting.jpg new file mode 100644 index 0000000..b0803ef Binary files /dev/null and b/docs/screenshot-waiting.jpg differ diff --git a/resources/lang/en/translations.php b/resources/lang/en/translations.php index 1010b82..7a77d1e 100644 --- a/resources/lang/en/translations.php +++ b/resources/lang/en/translations.php @@ -6,14 +6,18 @@ 'navigation_label' => 'Jobs', 'navigation_group' => 'Job Manager', 'total_jobs' => 'Total Jobs Executed', + 'waiting_jobs' => 'Total Jobs Waiting', 'execution_time' => 'Total Execution Time', 'average_time' => 'Average Execution Time', 'succeeded' => 'Succeeded', 'failed' => 'Failed', 'running' => 'Running', + 'waiting' => 'Waiting', 'status' => 'Status', + 'attempts' => 'Attempts', 'name' => 'Name', 'queue' => 'Queue', 'progress' => 'Progress', 'started_at' => 'Started at', + 'created_at' => 'Created at', ]; diff --git a/src/FilamentJobManagerPlugin.php b/src/FilamentJobsPlugin.php similarity index 99% rename from src/FilamentJobManagerPlugin.php rename to src/FilamentJobsPlugin.php index 59a95bd..31e8029 100644 --- a/src/FilamentJobManagerPlugin.php +++ b/src/FilamentJobsPlugin.php @@ -7,7 +7,7 @@ use Filament\Panel; use Filament\Support\Concerns\EvaluatesClosures; -class FilamentJobManagerPlugin implements Plugin +class FilamentJobsPlugin implements Plugin { use EvaluatesClosures; diff --git a/src/FilamentWaitingJobsPlugin.php b/src/FilamentWaitingJobsPlugin.php new file mode 100644 index 0000000..2920df3 --- /dev/null +++ b/src/FilamentWaitingJobsPlugin.php @@ -0,0 +1,293 @@ +resources([ + $this->getResource(), + ]); + } + + /** + * Boot the plugin. + */ + public function boot(Panel $panel): void + { + // + } + + /** + * Make a new instance of the plugin. + */ + public static function make(): static + { + return app(static::class); + } + + /** + * Get the plugin instance. + */ + public static function get(): static + { + return filament(app(static::class)->getId()); + } + + /** + * Get the resource class. + */ + public function getResource(): string + { + return $this->resource ?? config('filament-job-manager.resources.jobs_waiting.resource'); + } + + /** + * Set the resource class. + */ + public function resource(string $resource): static + { + $this->resource = $resource; + + return $this; + } + + /** + * Get the resource label. + */ + public function getLabel(): ?string + { + return $this->evaluate($this->label) ?? config('filament-job-manager.resources.jobs_waiting.label'); + } + + /** + * Set the resource label. + */ + public function label(string $label): static + { + $this->label = $label; + + return $this; + } + + /** + * Get the plural resource label. + */ + public function getPluralLabel(): ?string + { + return $this->evaluate($this->pluralLabel) ?? config('filament-job-manager.resources.jobs_waiting.plural_label'); + } + + /** + * Set the plural resource label. + */ + public function pluralLabel(string $pluralLabel): static + { + $this->pluralLabel = $pluralLabel; + + return $this; + } + + /** + * Get the resource navigation group. + */ + public function getNavigationGroup(): ?string + { + return $this->navigationGroup ?? config('filament-job-manager.resources.jobs_waiting.navigation_group'); + } + + /** + * Set the resource navigation group. + */ + public function navigationGroup(string $navigationGroup): static + { + $this->navigationGroup = $navigationGroup; + + return $this; + } + + /** + * Get the resource icon. + */ + public function getNavigationIcon(): ?string + { + return $this->navigationIcon ?? config('filament-job-manager.resources.jobs_waiting.navigation_icon'); + } + + /** + * Set the resource icon. + */ + public function navigationIcon(string $navigationIcon): static + { + $this->navigationIcon = $navigationIcon; + + return $this; + } + + /** + * Get the resource sort. + */ + public function getNavigationSort(): ?int + { + return $this->navigationSort ?? config('filament-job-manager.resources.jobs_waiting.navigation_sort'); + } + + /** + * Set the resource sort. + */ + public function navigationSort(int $navigationSort): static + { + $this->navigationSort = $navigationSort; + + return $this; + } + + /** + * Get the resource navigation count badge status. + */ + public function getNavigationCountBadge(): ?bool + { + return $this->navigationCountBadge ?? config('filament-job-manager.resources.jobs_waiting.navigation_count_badge'); + } + + /** + * Set the resource navigation count badge status. + */ + public function navigationCountBadge(bool $navigationCountBadge = true): static + { + $this->navigationCountBadge = $navigationCountBadge; + + return $this; + } + + /** + * Determine whether the resource navigation is enabled. + */ + public function shouldRegisterNavigation(): bool + { + return $this->navigation ?? config('filament-job-manager.resources.jobs_waiting.enabled'); + } + + /** + * Enable the resource navigation. + */ + public function enableNavigation(bool $status = true): static + { + $this->navigation = $status; + + return $this; + } + + /** + * Get the pruning status. + */ + public function getPruning(): ?bool + { + return $this->pruning ?? config('filament-jobs.pruning.enabled'); + } + + /** + * Set the pruning status. + */ + public function enablePruning(bool $status = true): static + { + $this->pruning = $status; + + return $this; + } + + /** + * Get the pruning retention. + */ + public function getPruningRetention(): ?int + { + return $this->pruningRetention ?? config('filament-jobs.pruning.retention_days'); + } + + /** + * Set the pruning retention. + */ + public function pruningRetention(int $pruningRetention): static + { + $this->pruningRetention = $pruningRetention; + + return $this; + } + + /** + * Get the resource breadcrumb. + */ + public function getBreadcrumb(): string + { + return __('filament-job-manager::translations.breadcrumb'); + } +} diff --git a/src/Models/Job.php b/src/Models/Job.php new file mode 100644 index 0000000..e4f69c9 --- /dev/null +++ b/src/Models/Job.php @@ -0,0 +1,46 @@ +reserved_at) { + return 'running'; + } else { + return 'waiting'; + } + }, + ); + } + + /* + *-------------------------------------------------------------------------- + * Methods + *-------------------------------------------------------------------------- + */ + + public function getDisplayNameAttribute() + { + $payload = json_decode($this->attributes['payload'], true); + + return $payload['displayName'] ?? null; + } +} diff --git a/src/Resources/JobsResource.php b/src/Resources/JobsResource.php index 61d1cd7..e47b481 100644 --- a/src/Resources/JobsResource.php +++ b/src/Resources/JobsResource.php @@ -2,7 +2,7 @@ namespace Adrolli\FilamentJobManager\Resources; -use Adrolli\FilamentJobManager\FilamentJobManagerPlugin; +use Adrolli\FilamentJobManager\FilamentJobsPlugin; use Adrolli\FilamentJobManager\Models\JobManager; use Adrolli\FilamentJobManager\Resources\JobsResource\Pages\ListJobs; use Adrolli\FilamentJobManager\Resources\JobsResource\Widgets\JobStatsOverview; @@ -73,6 +73,7 @@ public static function table(Table $table): Table ->since() ->sortable(), ]) + ->defaultSort('id', 'desc') ->bulkActions([ DeleteBulkAction::make(), ]); @@ -101,17 +102,17 @@ public static function getWidgets(): array public static function getNavigationBadge(): ?string { - return FilamentJobManagerPlugin::get()->getNavigationCountBadge() ? number_format(static::getModel()::count()) : null; + return FilamentJobsPlugin::get()->getNavigationCountBadge() ? number_format(static::getModel()::count()) : null; } public static function getModelLabel(): string { - return FilamentJobManagerPlugin::get()->getLabel(); + return FilamentJobsPlugin::get()->getLabel(); } public static function getPluralModelLabel(): string { - return FilamentJobManagerPlugin::get()->getPluralLabel(); + return FilamentJobsPlugin::get()->getPluralLabel(); } public static function getNavigationLabel(): string @@ -121,26 +122,26 @@ public static function getNavigationLabel(): string public static function getNavigationGroup(): ?string { - return FilamentJobManagerPlugin::get()->getNavigationGroup(); + return FilamentJobsPlugin::get()->getNavigationGroup(); } public static function getNavigationSort(): ?int { - return FilamentJobManagerPlugin::get()->getNavigationSort(); + return FilamentJobsPlugin::get()->getNavigationSort(); } public static function getBreadcrumb(): string { - return FilamentJobManagerPlugin::get()->getBreadcrumb(); + return FilamentJobsPlugin::get()->getBreadcrumb(); } public static function shouldRegisterNavigation(): bool { - return FilamentJobManagerPlugin::get()->shouldRegisterNavigation(); + return FilamentJobsPlugin::get()->shouldRegisterNavigation(); } public static function getNavigationIcon(): string { - return FilamentJobManagerPlugin::get()->getNavigationIcon(); + return FilamentJobsPlugin::get()->getNavigationIcon(); } } diff --git a/src/Resources/JobsResource/Widgets/JobStatsOverview.php b/src/Resources/JobsResource/Widgets/JobStatsOverview.php index d625567..76ce9e4 100644 --- a/src/Resources/JobsResource/Widgets/JobStatsOverview.php +++ b/src/Resources/JobsResource/Widgets/JobStatsOverview.php @@ -3,18 +3,21 @@ namespace Adrolli\FilamentJobManager\Resources\JobsResource\Widgets; use Adrolli\FilamentJobManager\Models\JobManager; +use Adrolli\FilamentJobManager\Traits\FormatSeconds; use Filament\Widgets\StatsOverviewWidget as BaseWidget; use Filament\Widgets\StatsOverviewWidget\Stat; use Illuminate\Support\Facades\DB; class JobStatsOverview extends BaseWidget { + use FormatSeconds; + protected function getCards(): array { $aggregationColumns = [ DB::raw('COUNT(*) as count'), - DB::raw('SUM(2 - 1) as total_time_elapsed'), - DB::raw('AVG(2 - 1) as average_time_elapsed'), + DB::raw('SUM(finished_at - started_at) as total_time_elapsed'), + DB::raw('AVG(finished_at - started_at) as average_time_elapsed'), ]; $aggregatedInfo = JobManager::query() @@ -23,7 +26,7 @@ protected function getCards(): array return [ Stat::make(__('filament-job-manager::translations.total_jobs'), $aggregatedInfo->count ?? 0), - Stat::make(__('filament-job-manager::translations.execution_time'), ($aggregatedInfo->total_time_elapsed ?? 0).'s'), + Stat::make(__('filament-job-manager::translations.execution_time'), ($this->formatSeconds($aggregatedInfo->total_time_elapsed) ?? '0 s')), Stat::make(__('filament-job-manager::translations.average_time'), ceil((float) $aggregatedInfo->average_time_elapsed).'s' ?? 0), ]; } diff --git a/src/Resources/WaitingJobsResource.php b/src/Resources/WaitingJobsResource.php new file mode 100644 index 0000000..5180352 --- /dev/null +++ b/src/Resources/WaitingJobsResource.php @@ -0,0 +1,149 @@ +schema([ + TextInput::make('job_id') + ->required() + ->maxLength(255), + TextInput::make('name') + ->maxLength(255), + TextInput::make('queue') + ->maxLength(255), + DateTimePicker::make('started_at'), + DateTimePicker::make('finished_at'), + Toggle::make('failed') + ->required(), + TextInput::make('attempt') + ->required(), + Textarea::make('exception_message') + ->maxLength(65535), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('status') + ->badge() + ->label(__('filament-job-manager::translations.status')) + ->sortable() + ->formatStateUsing(fn (string $state): string => __("filament-job-manager::translations.{$state}")) + ->color(fn (string $state): string => match ($state) { + 'running' => 'primary', + 'waiting' => 'success', + 'failed' => 'danger', + }), + TextColumn::make('display_name') + ->label(__('filament-job-manager::translations.name')) + ->sortable(), + TextColumn::make('queue') + ->label(__('filament-job-manager::translations.queue')) + ->sortable(), + TextColumn::make('attempts') + ->label(__('filament-job-manager::translations.attempts')) + ->sortable(), + TextColumn::make('reserved_at') + ->label(__('filament-job-manager::translations.created_at')) + ->since() + ->sortable(), + TextColumn::make('created_at') + ->label(__('filament-job-manager::translations.created_at')) + ->since() + ->sortable(), + ]) + ->defaultSort('id', 'asc') + ->bulkActions([ + DeleteBulkAction::make(), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListJobsWaiting::route('/'), + ]; + } + + public static function getWidgets(): array + { + return [ + JobsWaitingOverview::class, + ]; + } + + public static function getNavigationBadge(): ?string + { + return FilamentWaitingJobsPlugin::get()->getNavigationCountBadge() ? number_format(static::getModel()::count()) : null; + } + + public static function getModelLabel(): string + { + return FilamentWaitingJobsPlugin::get()->getLabel(); + } + + public static function getPluralModelLabel(): string + { + return FilamentWaitingJobsPlugin::get()->getPluralLabel(); + } + + public static function getNavigationLabel(): string + { + return Str::title(static::getPluralModelLabel()) ?? Str::title(static::getModelLabel()); + } + + public static function getNavigationGroup(): ?string + { + return FilamentWaitingJobsPlugin::get()->getNavigationGroup(); + } + + public static function getNavigationSort(): ?int + { + return FilamentWaitingJobsPlugin::get()->getNavigationSort(); + } + + public static function getBreadcrumb(): string + { + return FilamentWaitingJobsPlugin::get()->getBreadcrumb(); + } + + public static function shouldRegisterNavigation(): bool + { + return FilamentWaitingJobsPlugin::get()->shouldRegisterNavigation(); + } + + public static function getNavigationIcon(): string + { + return FilamentWaitingJobsPlugin::get()->getNavigationIcon(); + } +} diff --git a/src/Resources/WaitingJobsResource/Pages/ListJobsWaiting.php b/src/Resources/WaitingJobsResource/Pages/ListJobsWaiting.php new file mode 100644 index 0000000..aeab91b --- /dev/null +++ b/src/Resources/WaitingJobsResource/Pages/ListJobsWaiting.php @@ -0,0 +1,29 @@ +select(DB::raw('COUNT(*) as count')) + ->first(); + + $aggregationColumns = [ + DB::raw('SUM(finished_at - started_at) as total_time_elapsed'), + DB::raw('AVG(finished_at - started_at) as average_time_elapsed'), + ]; + + $aggregatedInfo = JobManager::query() + ->select($aggregationColumns) + ->first(); + + return [ + Stat::make(__('filament-job-manager::translations.waiting_jobs'), $jobsWaiting->count ?? 0), + Stat::make(__('filament-job-manager::translations.execution_time'), ($this->formatSeconds($aggregatedInfo->total_time_elapsed) ?? '0 s')), + Stat::make(__('filament-job-manager::translations.average_time'), ceil((float) $aggregatedInfo->average_time_elapsed).'s' ?? 0), + ]; + } +} diff --git a/src/Traits/FormatSeconds.php b/src/Traits/FormatSeconds.php new file mode 100644 index 0000000..2536145 --- /dev/null +++ b/src/Traits/FormatSeconds.php @@ -0,0 +1,40 @@ + 0) { + $formattedSeconds .= "$days d "; + } + + if ($hours > 0 or $days > 0) { + $formattedSeconds .= "$hours h "; + } + + if ($minutes > 0 or $hours > 0 or $days > 0) { + $formattedSeconds .= "$minutes m "; + } + + if ($days = 0) { + if ($seconds > 0 or $minutes > 0 or $hours > 0) { + $formattedSeconds .= "$seconds s"; + } + } + + return $formattedSeconds; + } +}