Skip to content

Commit e51fec2

Browse files
feat: add kernel schedule extraction with bulk import functionality
- Add extractFromKernel method to extract schedules from Laravel Kernel - Create ImportService to handle schedule import logic with duplicate prevention - Add importFromKernel method with upsert functionality (create new or update existing) - Create extract.blade.php view with bulk selection interface
1 parent 4412a50 commit e51fec2

File tree

7 files changed

+319
-5
lines changed

7 files changed

+319
-5
lines changed

resources/lang/en/schedule.php

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
'create' => 'Create new schedule',
66
'edit' => 'Edit schedule',
77
'show' => 'Show run history',
8-
'back_to_application' => 'Back to application'
8+
'back_to_application' => 'Back to application',
9+
'extract' => 'Extract Schedules from Kernel'
910
],
1011
'fields' => [
1112
'command' => 'Command',
@@ -33,7 +34,8 @@
3334
'updated_at' => 'Updated At',
3435
'never' => 'Never',
3536
'groups' => 'Groups',
36-
'environments' => 'Environments'
37+
'environments' => 'Environments',
38+
'settings' => 'Settings'
3739
],
3840
'messages' => [
3941
'no-records-found' => 'No records found.',
@@ -48,7 +50,13 @@
4850
'help-type' => 'Multiple :type can be specified separated by commas',
4951
'attention-type-function' => "ATTENTION: parameters of the type 'function' are executed before the execution of the scheduling and its return is passed as parameter. Use with care, it can break your job",
5052
'delete_cronjob' => 'Delete cronjob',
51-
'delete_cronjob_confirm' => 'Do you really want to delete the cronjob ":cronjob"?'
53+
'delete_cronjob_confirm' => 'Do you really want to delete the cronjob ":cronjob"?',
54+
'import-success' => 'Schedules extracted successfully',
55+
'import-error' => 'Error extracting schedules',
56+
'no-schedules-found' => 'No schedules found in Kernel.',
57+
'all-environments' => 'All',
58+
'no-overlap' => 'No Overlap',
59+
'one-server' => 'One Server'
5260
],
5361
'status' => [
5462
'active' => 'Active',
@@ -65,7 +73,11 @@
6573
'delete' => 'Delete',
6674
'history' => 'History',
6775
'cancel' => 'Cancel',
68-
'restore' => 'Restore'
76+
'restore' => 'Restore',
77+
'extract_kernel' => 'Extract from Kernel',
78+
'select_all' => 'Select All',
79+
'select_none' => 'Select None',
80+
'import_selected' => 'Import Selected'
6981
],
7082
'validation' => [
7183
'cron' => 'The field must be filled in the cron expression format.',

resources/views/extract.blade.php

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
@extends('schedule::layout.master')
2+
3+
@section('content')
4+
<div class="container">
5+
<div class="row justify-content-center">
6+
<div class="col-md-12">
7+
<div class="card">
8+
<div class="card-header d-flex justify-content-between align-items-center">
9+
<span>{{ trans('schedule::schedule.titles.extract') }}</span>
10+
<a href="{{ action('\RobersonFaria\DatabaseSchedule\Http\Controllers\ScheduleController@index') }}"
11+
class="btn btn-secondary btn-sm">
12+
{{ trans('schedule::schedule.buttons.back') }}
13+
</a>
14+
</div>
15+
16+
<div class="card-body">
17+
@include('schedule::messages')
18+
19+
@if(count($extractedSchedules) > 0)
20+
<form method="POST" action="{{ route(config('database-schedule.route.name', 'database-schedule') . '.import') }}">
21+
@csrf
22+
23+
<div class="mb-3">
24+
<button type="button" class="btn btn-sm btn-outline-primary" onclick="selectAll()">
25+
{{ trans('schedule::schedule.buttons.select_all') }}
26+
</button>
27+
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectNone()">
28+
{{ trans('schedule::schedule.buttons.select_none') }}
29+
</button>
30+
<button type="submit" class="btn btn-success btn-sm ml-2">
31+
{{ trans('schedule::schedule.buttons.import_selected') }}
32+
</button>
33+
</div>
34+
35+
<div class="table-responsive">
36+
<table class="table table-striped">
37+
<thead>
38+
<tr>
39+
<th style="width: 50px;">
40+
<input type="checkbox" id="selectAllCheckbox" onchange="toggleAll()">
41+
</th>
42+
<th>{{ trans('schedule::schedule.fields.command') }}</th>
43+
<th>{{ trans('schedule::schedule.fields.expression') }}</th>
44+
<th>{{ trans('schedule::schedule.fields.environments') }}</th>
45+
<th>{{ trans('schedule::schedule.fields.settings') }}</th>
46+
</tr>
47+
</thead>
48+
<tbody>
49+
@foreach($extractedSchedules as $index => $schedule)
50+
<tr>
51+
<td>
52+
<input type="checkbox"
53+
name="schedules[]"
54+
value="{{ json_encode($schedule) }}"
55+
class="schedule-checkbox">
56+
</td>
57+
<td>
58+
<strong>{{ $schedule['command'] }}</strong>
59+
@if(!empty($schedule['params']))
60+
<br>
61+
@foreach($schedule['params'] as $key => $param)
62+
<small class="text-muted">{{ $key }}: {{ $param }}</small>
63+
@endforeach
64+
@endif
65+
</td>
66+
<td><code>{{ $schedule['expression'] }}</code></td>
67+
<td>
68+
@if($schedule['environments'])
69+
<span class="badge badge-warning">{{ $schedule['environments'] }}</span>
70+
@else
71+
<span class="text-muted">{{ trans('schedule::schedule.messages.all-environments') }}</span>
72+
@endif
73+
</td>
74+
<td>
75+
@if($schedule['without_overlapping'])
76+
<span class="badge badge-secondary">{{ trans('schedule::schedule.messages.no-overlap') }}</span>
77+
@endif
78+
@if($schedule['on_one_server'])
79+
<span class="badge badge-info">{{ trans('schedule::schedule.messages.one-server') }}</span>
80+
@endif
81+
@if($schedule['status'])
82+
<span class="badge badge-success">{{ trans('schedule::schedule.status.active') }}</span>
83+
@else
84+
<span class="badge badge-danger">{{ trans('schedule::schedule.status.inactive') }}</span>
85+
@endif
86+
</td>
87+
</tr>
88+
@endforeach
89+
</tbody>
90+
</table>
91+
</div>
92+
</form>
93+
@else
94+
<div class="alert alert-info">
95+
{{ trans('schedule::schedule.messages.no-schedules-found') }}
96+
</div>
97+
@endif
98+
</div>
99+
</div>
100+
</div>
101+
</div>
102+
</div>
103+
104+
<script>
105+
function selectAll() {
106+
document.querySelectorAll('.schedule-checkbox').forEach(cb => cb.checked = true);
107+
document.getElementById('selectAllCheckbox').checked = true;
108+
}
109+
110+
function selectNone() {
111+
document.querySelectorAll('.schedule-checkbox').forEach(cb => cb.checked = false);
112+
document.getElementById('selectAllCheckbox').checked = false;
113+
}
114+
115+
function toggleAll() {
116+
const selectAll = document.getElementById('selectAllCheckbox').checked;
117+
document.querySelectorAll('.schedule-checkbox').forEach(cb => cb.checked = selectAll);
118+
}
119+
</script>
120+
@endsection

resources/views/index.blade.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ class="bi {{ ($schedule->status ? 'bi-pause' : 'bi-play') }}"></i>
122122
</div>
123123
</div>
124124
<div class="card-footer text-right">
125+
<a href="{{ route(config('database-schedule.route.name', 'database-schedule') . '.extract') }}"
126+
class="btn btn-primary">
127+
<i class="bi bi-download"></i> {{ trans('schedule::schedule.buttons.extract_kernel') }}
128+
</a>
125129
<a href="{{ action('\RobersonFaria\DatabaseSchedule\Http\Controllers\ScheduleController@create') }}"
126130
class="btn btn-primary">
127131
{{ trans('schedule::schedule.buttons.create') }}

routes/web.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@
1818
->name(config('database-schedule.route.name', 'database-schedule') . '.filter');
1919
Route::post('/filter-reset', 'ScheduleController@filterReset')
2020
->name(config('database-schedule.route.name', 'database-schedule') . '.filter-reset');
21-
Route::get('/create', 'ScheduleController@create')
21+
Route::get('/create2', 'ScheduleController@create')
2222
->name(config('database-schedule.route.name', 'database-schedule') . '.create');
23+
Route::get('/extract', 'ScheduleController@extractFromKernel')
24+
->name(config('database-schedule.route.name', 'database-schedule') . '.extract');
25+
Route::post('/import', 'ScheduleController@importFromKernel')
26+
->name(config('database-schedule.route.name', 'database-schedule') . '.import');
2327
Route::put('/{schedule}', 'ScheduleController@update')
2428
->name(config('database-schedule.route.name', 'database-schedule') . '.update');
2529
Route::get('/{schedule}', 'ScheduleController@show')

src/Http/Controllers/ScheduleController.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
use RobersonFaria\DatabaseSchedule\Http\Requests\ScheduleRequest;
66
use RobersonFaria\DatabaseSchedule\Http\Services\CommandService;
7+
use RobersonFaria\DatabaseSchedule\Http\Services\ImportService;
8+
use RobersonFaria\DatabaseSchedule\Http\Services\KernelExtractorService;
79
use RobersonFaria\DatabaseSchedule\Models\Schedule;
810
use RobersonFaria\DatabaseSchedule\View\Helpers;
911

@@ -63,6 +65,9 @@ public function index()
6365
return view('schedule::index')->with(compact('schedules', 'orderBy', 'direction'));
6466
}
6567

68+
/**
69+
* @return \Illuminate\Http\RedirectResponse
70+
*/
6671
public function filter()
6772
{
6873
session()->put(Schedule::SESSION_KEY_FILTERS, request()->input('filters'));
@@ -155,6 +160,11 @@ public function update(ScheduleRequest $request, Schedule $schedule)
155160
}
156161
}
157162

163+
/**
164+
* @param Schedule $schedule
165+
* @param bool $status
166+
* @return \Illuminate\Http\RedirectResponse
167+
*/
158168
public function status(Schedule $schedule, bool $status)
159169
{
160170
try {
@@ -224,4 +234,39 @@ public function filterReset()
224234

225235
return redirect()->to(Helpers::indexRoute());
226236
}
237+
238+
/**
239+
* Extract schedules from Kernel
240+
*
241+
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\Http\Response|\Illuminate\View\View
242+
*/
243+
public function extractFromKernel()
244+
{
245+
$extractorService = new KernelExtractorService();
246+
$extractedSchedules = $extractorService->extract();
247+
248+
return view('schedule::extract')
249+
->with(compact('extractedSchedules'));
250+
}
251+
252+
/**
253+
* Import selected schedules from kernel extraction
254+
*
255+
* @return \Illuminate\Http\RedirectResponse
256+
*/
257+
public function importFromKernel(ImportService $importService)
258+
{
259+
try {
260+
$selectedSchedules = request()->input('schedules', []);
261+
262+
$importService->importSchedules($selectedSchedules);
263+
264+
return redirect()->to(Helpers::indexRoute())
265+
->with('success', trans('schedule::schedule.messages.import-success'));
266+
} catch (\Exception $e) {
267+
report($e);
268+
return back()
269+
->with('error', trans('schedule::schedule.messages.import-error') . ' : ' . $e->getMessage());
270+
}
271+
}
227272
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace RobersonFaria\DatabaseSchedule\Http\Services;
4+
5+
class ImportService
6+
{
7+
public function importSchedules(array $selectedSchedules): void
8+
{
9+
$modelClass = config('database-schedule.model');
10+
11+
foreach ($selectedSchedules as $scheduleData) {
12+
$data = json_decode($scheduleData, true, 512, JSON_THROW_ON_ERROR);
13+
14+
$modelClass::updateOrCreate(
15+
[
16+
'command' => $data['command'],
17+
'expression' => $data['expression'],
18+
],
19+
$data
20+
);
21+
}
22+
}
23+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
namespace RobersonFaria\DatabaseSchedule\Http\Services;
4+
5+
use Illuminate\Console\Scheduling\Schedule;
6+
use Illuminate\Contracts\Console\Kernel;
7+
use Illuminate\Support\Facades\App;
8+
use ReflectionClass;
9+
use ReflectionMethod;
10+
11+
class KernelExtractorService
12+
{
13+
14+
public function extract(): array
15+
{
16+
$kernel = App::make(Kernel::class);
17+
$schedule = App::make(Schedule::class);
18+
19+
$method = new ReflectionMethod($kernel, 'schedule');
20+
$method->setAccessible(true);
21+
$method->invoke($kernel, $schedule);
22+
23+
$extractedSchedules = [];
24+
25+
foreach ($schedule->events() as $event) {
26+
$extractedSchedules[] = $this->parseEvent($event);
27+
}
28+
29+
return $extractedSchedules;
30+
}
31+
32+
private function parseEvent($event): array
33+
{
34+
$command = $this->getCommand($event);
35+
36+
// Get cron expression
37+
$expression = $event->getExpression();
38+
39+
// Get configurations
40+
$withoutOverlapping = $this->getProperty($event, 'withoutOverlapping');
41+
$onOneServer = $this->getProperty($event, 'onOneServer');
42+
$environments = $this->getEnvironments($event);
43+
44+
return [
45+
'command' => $command['command'],
46+
'params' => $command['params'],
47+
'expression' => $expression,
48+
'without_overlapping' => $withoutOverlapping,
49+
'on_one_server' => $onOneServer,
50+
'environments' => $environments,
51+
'status' => true,
52+
];
53+
}
54+
55+
private function getCommand($event): array
56+
{
57+
if (!empty($event->description)) {
58+
return [
59+
'command' => $event->description,
60+
'params' => []
61+
];
62+
}
63+
64+
$fullCommand = $event->command ?? '';
65+
66+
$fullCommand = str_replace(["'/usr/local/bin/php'", "'artisan'"], '', $fullCommand);
67+
$fullCommand = trim(preg_replace('/\s+/', ' ', $fullCommand));
68+
69+
$parts = explode(' ', $fullCommand);
70+
$command = $parts[0] ?? '';
71+
$params = array_slice($parts, 1);
72+
73+
return [
74+
'command' => $command,
75+
'params' => $this->formatParams($params),
76+
];
77+
}
78+
79+
private function formatParams(array $params): array
80+
{
81+
$formatted = [];
82+
foreach ($params as $index => $param) {
83+
$formatted["arg{$index}"] = ['value' => $param];
84+
}
85+
return $formatted;
86+
}
87+
88+
private function getProperty($event, string $property)
89+
{
90+
try {
91+
$reflection = new ReflectionClass($event);
92+
$prop = $reflection->getProperty($property);
93+
$prop->setAccessible(true);
94+
95+
return $prop->getValue($event);
96+
} catch (\Exception $e) {
97+
return false;
98+
}
99+
}
100+
101+
private function getEnvironments($event): ?string
102+
{
103+
$environments = $this->getProperty($event, 'environments');
104+
return $environments ? implode(',', $environments) : null;
105+
}
106+
}

0 commit comments

Comments
 (0)