From bf37548faab6ec7bd58fd413d3de3633bdea5b07 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 24 Jul 2026 12:38:06 +0100 Subject: [PATCH] Automation administration dashboard * Manual automations * Scheduled automations * Incoming webhook triggers * Domain-event trigger foundation * Ordered automation steps * Queued execution * Run status and execution history * Per-step execution logs * Continue-or-stop-on-failure control * Email actions * Outgoing webhook actions * Application log actions * Delay actions * Secure incoming webhook secrets * Automation trigger and action registries * Extensible action and trigger contracts * Scheduled automation Artisan command * Administration navigation * Automation permissions * Module registration and boot test --- Modules/Automation/Config/config.php | 7 +++ .../Console/DispatchScheduledAutomations.php | 15 ++++++ .../Automation/Contracts/ActionRegistry.php | 8 +++ .../Automation/Contracts/TriggerRegistry.php | 7 +++ ..._07_24_130000_create_automation_tables.php | 41 +++++++++++++++ .../Controllers/AutomationApiController.php | 16 ++++++ .../Http/Controllers/AutomationController.php | 35 +++++++++++++ Modules/Automation/Jobs/ExecuteAutomation.php | 28 +++++++++++ Modules/Automation/Models/Automation.php | 12 +++++ Modules/Automation/Models/AutomationRun.php | 12 +++++ .../Automation/Models/AutomationRunLog.php | 9 ++++ Modules/Automation/Models/AutomationStep.php | 10 ++++ .../Providers/AutomationServiceProvider.php | 24 +++++++++ .../Providers/RouteServiceProvider.php | 12 +++++ .../Resources/views/admin/index.blade.php | 26 ++++++++++ Modules/Automation/Routes/api.php | 4 ++ Modules/Automation/Routes/web.php | 11 ++++ .../Services/AutomationActionRegistry.php | 50 +++++++++++++++++++ .../Automation/Services/AutomationRunner.php | 28 +++++++++++ .../Services/AutomationTriggerRegistry.php | 16 ++++++ .../Tests/Feature/ModuleBootTest.php | 11 ++++ Modules/Automation/module.json | 1 + .../Database/Seeders/RolePermissionSeeder.php | 3 ++ modules_statuses.json | 3 +- .../components/layouts/app/sidebar.blade.php | 1 + 25 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 Modules/Automation/Config/config.php create mode 100644 Modules/Automation/Console/DispatchScheduledAutomations.php create mode 100644 Modules/Automation/Contracts/ActionRegistry.php create mode 100644 Modules/Automation/Contracts/TriggerRegistry.php create mode 100644 Modules/Automation/Database/Migrations/2026_07_24_130000_create_automation_tables.php create mode 100644 Modules/Automation/Http/Controllers/AutomationApiController.php create mode 100644 Modules/Automation/Http/Controllers/AutomationController.php create mode 100644 Modules/Automation/Jobs/ExecuteAutomation.php create mode 100644 Modules/Automation/Models/Automation.php create mode 100644 Modules/Automation/Models/AutomationRun.php create mode 100644 Modules/Automation/Models/AutomationRunLog.php create mode 100644 Modules/Automation/Models/AutomationStep.php create mode 100644 Modules/Automation/Providers/AutomationServiceProvider.php create mode 100644 Modules/Automation/Providers/RouteServiceProvider.php create mode 100644 Modules/Automation/Resources/views/admin/index.blade.php create mode 100644 Modules/Automation/Routes/api.php create mode 100644 Modules/Automation/Routes/web.php create mode 100644 Modules/Automation/Services/AutomationActionRegistry.php create mode 100644 Modules/Automation/Services/AutomationRunner.php create mode 100644 Modules/Automation/Services/AutomationTriggerRegistry.php create mode 100644 Modules/Automation/Tests/Feature/ModuleBootTest.php create mode 100644 Modules/Automation/module.json diff --git a/Modules/Automation/Config/config.php b/Modules/Automation/Config/config.php new file mode 100644 index 0000000..1ef693a --- /dev/null +++ b/Modules/Automation/Config/config.php @@ -0,0 +1,7 @@ + env('AUTOMATION_QUEUE', 'default'), + 'max_steps' => (int) env('AUTOMATION_MAX_STEPS', 50), + 'webhook_timeout' => (int) env('AUTOMATION_WEBHOOK_TIMEOUT', 10), + 'retain_runs_days' => (int) env('AUTOMATION_RETAIN_RUNS_DAYS', 90), +]; diff --git a/Modules/Automation/Console/DispatchScheduledAutomations.php b/Modules/Automation/Console/DispatchScheduledAutomations.php new file mode 100644 index 0000000..80fd867 --- /dev/null +++ b/Modules/Automation/Console/DispatchScheduledAutomations.php @@ -0,0 +1,15 @@ +where('status','active')->where('trigger_type','schedule')->whereNotNull('next_run_at')->where('next_run_at','<=',now())->chunkById(100,function($items)use($runner,&$count){foreach($items as $automation){$runner->dispatch($automation,['scheduled_at'=>now()->toIso8601String()],'schedule');$interval=max(1,(int)($automation->trigger_config['interval_minutes']??60));$automation->update(['next_run_at'=>now()->addMinutes($interval)]);$count++;}}); + $this->info("Dispatched {$count} scheduled automation(s)."); return self::SUCCESS; + } +} diff --git a/Modules/Automation/Contracts/ActionRegistry.php b/Modules/Automation/Contracts/ActionRegistry.php new file mode 100644 index 0000000..7c7a41e --- /dev/null +++ b/Modules/Automation/Contracts/ActionRegistry.php @@ -0,0 +1,8 @@ +id(); $table->string('name'); $table->text('description')->nullable(); + $table->string('trigger_type')->index(); $table->json('trigger_config')->nullable(); + $table->string('status', 20)->default('draft')->index(); $table->boolean('stop_on_failure')->default(true); + $table->unsignedInteger('run_count')->default(0); $table->timestamp('last_ran_at')->nullable(); + $table->timestamp('next_run_at')->nullable()->index(); $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + Schema::create('automation_steps', function (Blueprint $table) { + $table->id(); $table->foreignId('automation_id')->constrained('automations')->cascadeOnDelete(); + $table->unsignedSmallInteger('position')->default(1); $table->string('action_type'); + $table->string('name')->nullable(); $table->json('configuration')->nullable(); $table->boolean('is_enabled')->default(true); + $table->timestamps(); $table->unique(['automation_id','position']); + }); + Schema::create('automation_runs', function (Blueprint $table) { + $table->id(); $table->foreignId('automation_id')->constrained('automations')->cascadeOnDelete(); + $table->uuid('execution_id')->unique(); $table->string('trigger_type'); $table->json('payload')->nullable(); + $table->string('status', 20)->default('queued')->index(); $table->unsignedSmallInteger('current_step')->default(0); + $table->text('error')->nullable(); $table->timestamp('started_at')->nullable(); $table->timestamp('finished_at')->nullable(); + $table->timestamps(); + }); + Schema::create('automation_run_logs', function (Blueprint $table) { + $table->id(); $table->foreignId('automation_run_id')->constrained('automation_runs')->cascadeOnDelete(); + $table->foreignId('automation_step_id')->nullable()->constrained('automation_steps')->nullOnDelete(); + $table->string('level', 20)->default('info'); $table->string('message'); $table->json('context')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + } + public function down(): void + { + Schema::dropIfExists('automation_run_logs'); Schema::dropIfExists('automation_runs'); + Schema::dropIfExists('automation_steps'); Schema::dropIfExists('automations'); + } +}; diff --git a/Modules/Automation/Http/Controllers/AutomationApiController.php b/Modules/Automation/Http/Controllers/AutomationApiController.php new file mode 100644 index 0000000..f361da5 --- /dev/null +++ b/Modules/Automation/Http/Controllers/AutomationApiController.php @@ -0,0 +1,16 @@ +trigger_type==='webhook',404); $secret=(string)($automation->trigger_config['secret']??''); + if($secret!=='') abort_unless(hash_equals($secret,(string)$request->header('X-Automation-Secret')),401,'Invalid automation secret.'); + $run=$runner->dispatch($automation,$request->all(),'webhook'); return response()->json(['data'=>['execution_id'=>$run->execution_id,'status'=>$run->status]],202); + } +} diff --git a/Modules/Automation/Http/Controllers/AutomationController.php b/Modules/Automation/Http/Controllers/AutomationController.php new file mode 100644 index 0000000..bde92c5 --- /dev/null +++ b/Modules/Automation/Http/Controllers/AutomationController.php @@ -0,0 +1,35 @@ +Automation::withCount(['steps','runs'])->latest()->paginate(20),'recentRuns'=>AutomationRun::with('automation')->latest()->limit(15)->get(),'triggers'=>$triggers->all(),'actions'=>$actions->all()]); + } + public function store(Request $request,TriggerRegistry $triggers): RedirectResponse + { + $data=$request->validate(['name'=>['required','string','max:255'],'description'=>['nullable','string'],'trigger_type'=>['required','string'],'status'=>['required','in:draft,active,paused'],'next_run_at'=>['nullable','date'],'trigger_config'=>['nullable','json']]); + abort_unless($triggers->has($data['trigger_type']),422,'Unknown trigger type.'); + $data['trigger_config']=filled($data['trigger_config']??null)?json_decode($data['trigger_config'],true,512,JSON_THROW_ON_ERROR):[]; $data['stop_on_failure']=$request->boolean('stop_on_failure'); $data['created_by']=$request->user()->id; + Automation::create($data); return back()->with('status','Automation created.'); + } + public function addStep(Request $request,Automation $automation,ActionRegistry $actions): RedirectResponse + { + $data=$request->validate(['action_type'=>['required','string'],'name'=>['nullable','string','max:255'],'configuration'=>['nullable','json']]); + abort_unless(array_key_exists($data['action_type'],$actions->all()),422,'Unknown action type.'); + $automation->steps()->create(['position'=>(int)$automation->steps()->max('position')+1,'action_type'=>$data['action_type'],'name'=>$data['name']??null,'configuration'=>filled($data['configuration']??null)?json_decode($data['configuration'],true,512,JSON_THROW_ON_ERROR):[],'is_enabled'=>true]); + return back()->with('status','Automation step added.'); + } + public function run(Automation $automation,AutomationRunner $runner): RedirectResponse { $run=$runner->dispatch($automation,['initiated_by'=>request()->user()->id],'manual'); return back()->with('status',"Automation queued as {$run->execution_id}."); } + public function toggle(Automation $automation): RedirectResponse { $automation->update(['status'=>$automation->status==='active'?'paused':'active']); return back()->with('status','Automation status updated.'); } + public function destroy(Automation $automation): RedirectResponse { $automation->delete(); return back()->with('status','Automation deleted.'); } +} diff --git a/Modules/Automation/Jobs/ExecuteAutomation.php b/Modules/Automation/Jobs/ExecuteAutomation.php new file mode 100644 index 0000000..dc46549 --- /dev/null +++ b/Modules/Automation/Jobs/ExecuteAutomation.php @@ -0,0 +1,28 @@ +findOrFail($this->runId); $automation=$run->automation; + $run->update(['status'=>'running','started_at'=>now()]); + try { + foreach($automation->steps->where('is_enabled',true) as $step) { + $run->update(['current_step'=>$step->position]); + try { $result=$actions->execute($step,$run->payload??[]); $run->logs()->create(['automation_step_id'=>$step->id,'level'=>'info','message'=>$step->name?:$step->action_type.' completed','context'=>$result,'created_at'=>now()]); } + catch(\Throwable $e) { $run->logs()->create(['automation_step_id'=>$step->id,'level'=>'error','message'=>$e->getMessage(),'created_at'=>now()]); if($automation->stop_on_failure) throw $e; } + } + $run->update(['status'=>'completed','finished_at'=>now()]); + } catch(\Throwable $e) { $run->update(['status'=>'failed','error'=>$e->getMessage(),'finished_at'=>now()]); throw $e; } + finally { $automation->increment('run_count'); $automation->update(['last_ran_at'=>now()]); } + } +} diff --git a/Modules/Automation/Models/Automation.php b/Modules/Automation/Models/Automation.php new file mode 100644 index 0000000..4f8a82d --- /dev/null +++ b/Modules/Automation/Models/Automation.php @@ -0,0 +1,12 @@ +'array','stop_on_failure'=>'boolean','last_ran_at'=>'datetime','next_run_at'=>'datetime']; + public function steps(): HasMany { return $this->hasMany(AutomationStep::class)->orderBy('position'); } + public function runs(): HasMany { return $this->hasMany(AutomationRun::class); } + public function isActive(): bool { return $this->status === 'active'; } +} diff --git a/Modules/Automation/Models/AutomationRun.php b/Modules/Automation/Models/AutomationRun.php new file mode 100644 index 0000000..62febea --- /dev/null +++ b/Modules/Automation/Models/AutomationRun.php @@ -0,0 +1,12 @@ +'array','started_at'=>'datetime','finished_at'=>'datetime']; + public function automation(): BelongsTo { return $this->belongsTo(Automation::class); } + public function logs(): HasMany { return $this->hasMany(AutomationRunLog::class); } +} diff --git a/Modules/Automation/Models/AutomationRunLog.php b/Modules/Automation/Models/AutomationRunLog.php new file mode 100644 index 0000000..4943c03 --- /dev/null +++ b/Modules/Automation/Models/AutomationRunLog.php @@ -0,0 +1,9 @@ +'array','created_at'=>'datetime']; +} diff --git a/Modules/Automation/Models/AutomationStep.php b/Modules/Automation/Models/AutomationStep.php new file mode 100644 index 0000000..620a5c8 --- /dev/null +++ b/Modules/Automation/Models/AutomationStep.php @@ -0,0 +1,10 @@ +'array','is_enabled'=>'boolean']; + public function automation(): BelongsTo { return $this->belongsTo(Automation::class); } +} diff --git a/Modules/Automation/Providers/AutomationServiceProvider.php b/Modules/Automation/Providers/AutomationServiceProvider.php new file mode 100644 index 0000000..4a828c9 --- /dev/null +++ b/Modules/Automation/Providers/AutomationServiceProvider.php @@ -0,0 +1,24 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Automation', 'Config/config.php'), 'automation'); + $this->app->singleton(ActionRegistry::class, AutomationActionRegistry::class); + $this->app->singleton(TriggerRegistry::class, AutomationTriggerRegistry::class); + } + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Automation', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Automation', 'Resources/views'), 'automation'); + if ($this->app->runningInConsole()) $this->commands([DispatchScheduledAutomations::class]); + } +} diff --git a/Modules/Automation/Providers/RouteServiceProvider.php b/Modules/Automation/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..8f21665 --- /dev/null +++ b/Modules/Automation/Providers/RouteServiceProvider.php @@ -0,0 +1,12 @@ +group(module_path('Automation', 'Routes/web.php')); + Route::middleware('api')->prefix('api/v1')->group(module_path('Automation', 'Routes/api.php')); + } +} diff --git a/Modules/Automation/Resources/views/admin/index.blade.php b/Modules/Automation/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..1033167 --- /dev/null +++ b/Modules/Automation/Resources/views/admin/index.blade.php @@ -0,0 +1,26 @@ + +
+
Automation PlatformCreate event-driven, scheduled and webhook automations.
+@if(session('status')){{ session('status') }}@endif +
+Automations{{ $automations->total() }} +Active{{ \Modules\Automation\Models\Automation::where('status','active')->count() }} +Completed runs{{ \Modules\Automation\Models\AutomationRun::where('status','completed')->count() }} +Failed runs{{ \Modules\Automation\Models\AutomationRun::where('status','failed')->count() }} +
+@can('automation.manage') +Create automation
@csrf +@foreach($triggers as $key=>$trigger)@endforeach + + +
Create automation
+
+@endcan +
+@forelse($automations as $automation)
{{ $automation->name }}{{ $automation->trigger_type }} · {{ $automation->steps_count }} steps · {{ $automation->runs_count }} runs
{{ ucfirst($automation->status) }}
+@if($automation->description){{ $automation->description }}@endif +
@can('automation.execute')
@csrfRun
@endcan @can('automation.manage')
@csrf @method('PATCH'){{ $automation->status==='active'?'Pause':'Activate' }}
@csrf @method('DELETE')Delete
@endcan
+@can('automation.manage')
@csrf@foreach($actions as $key=>$action)@endforeach
Add step
@endcan +
@emptyNo automations have been created.@endforelse +{{ $automations->links() }}
Recent runs
@forelse($recentRuns as $run)
{{ $run->automation?->name }}{{ $run->status }}
{{ $run->execution_id }}
@emptyNo runs yet.@endforelse
+
diff --git a/Modules/Automation/Routes/api.php b/Modules/Automation/Routes/api.php new file mode 100644 index 0000000..29c6431 --- /dev/null +++ b/Modules/Automation/Routes/api.php @@ -0,0 +1,4 @@ +middleware('throttle:60,1')->name('api.automations.trigger'); diff --git a/Modules/Automation/Routes/web.php b/Modules/Automation/Routes/web.php new file mode 100644 index 0000000..d6e2c87 --- /dev/null +++ b/Modules/Automation/Routes/web.php @@ -0,0 +1,11 @@ +name('administration.automation.')->middleware(['auth','permission:automation.view'])->group(function(){ + Route::get('/',[AutomationController::class,'index'])->name('index'); + Route::post('/',[AutomationController::class,'store'])->middleware('permission:automation.manage')->name('store'); + Route::post('/{automation}/steps',[AutomationController::class,'addStep'])->middleware('permission:automation.manage')->name('steps.store'); + Route::post('/{automation}/run',[AutomationController::class,'run'])->middleware('permission:automation.execute')->name('run'); + Route::patch('/{automation}/toggle',[AutomationController::class,'toggle'])->middleware('permission:automation.manage')->name('toggle'); + Route::delete('/{automation}',[AutomationController::class,'destroy'])->middleware('permission:automation.manage')->name('destroy'); +}); diff --git a/Modules/Automation/Services/AutomationActionRegistry.php b/Modules/Automation/Services/AutomationActionRegistry.php new file mode 100644 index 0000000..1e941a5 --- /dev/null +++ b/Modules/Automation/Services/AutomationActionRegistry.php @@ -0,0 +1,50 @@ +['label'=>'Write log','fields'=>['message']], + 'webhook'=>['label'=>'Send webhook','fields'=>['url','method']], + 'email'=>['label'=>'Send email','fields'=>['to','subject','body']], + 'delay'=>['label'=>'Delay','fields'=>['seconds']], + ]; + } + public function execute(AutomationStep $step, array $payload): array + { + $config=$step->configuration ?? []; + return match($step->action_type) { + 'log'=>$this->log($config,$payload), + 'webhook'=>$this->webhook($config,$payload), + 'email'=>$this->email($config,$payload), + 'delay'=>$this->delay($config), + default=>throw new \InvalidArgumentException("Unknown automation action [{$step->action_type}]."), + }; + } + private function log(array $config,array $payload): array + { + $message=(string)($config['message']??'Automation executed'); Log::info($message,$payload); return ['message'=>$message]; + } + private function webhook(array $config,array $payload): array + { + $url=(string)($config['url']??''); if ($url==='') throw new \InvalidArgumentException('Webhook URL is required.'); + $method=strtolower((string)($config['method']??'post')); $response=Http::timeout(config('automation.webhook_timeout',10))->send($method,$url,['json'=>$payload]); + $response->throw(); return ['status'=>$response->status()]; + } + private function email(array $config,array $payload): array + { + $to=(string)($config['to']??''); if ($to==='') throw new \InvalidArgumentException('Email recipient is required.'); + $subject=(string)($config['subject']??'Automation notification'); $body=(string)($config['body']??json_encode($payload,JSON_PRETTY_PRINT)); + Mail::raw($body,fn($message)=>$message->to($to)->subject($subject)); return ['to'=>$to]; + } + private function delay(array $config): array + { + $seconds=max(0,min(30,(int)($config['seconds']??1))); if ($seconds>0) sleep($seconds); return ['seconds'=>$seconds]; + } +} diff --git a/Modules/Automation/Services/AutomationRunner.php b/Modules/Automation/Services/AutomationRunner.php new file mode 100644 index 0000000..70e7529 --- /dev/null +++ b/Modules/Automation/Services/AutomationRunner.php @@ -0,0 +1,28 @@ +isActive(),422,'Automation is not active.'); + $run=$automation->runs()->create(['execution_id'=>(string)Str::uuid(),'trigger_type'=>$triggerType??$automation->trigger_type,'payload'=>$payload,'status'=>'queued']); + ExecuteAutomation::dispatch($run->id)->onQueue(config('automation.queue','default')); + return $run; + } + public function dispatchMatching(string $triggerType,array $payload=[],array $constraints=[]): int + { + $query=Automation::query()->where('status','active')->where('trigger_type',$triggerType); + $count=0; foreach($query->get() as $automation) { if($this->matches($automation,$constraints)){ $this->dispatch($automation,$payload,$triggerType); $count++; } } + return $count; + } + private function matches(Automation $automation,array $constraints): bool + { + $config=$automation->trigger_config??[]; + foreach($constraints as $key=>$value) if(isset($config[$key]) && (string)$config[$key] !== (string)$value) return false; + return true; + } +} diff --git a/Modules/Automation/Services/AutomationTriggerRegistry.php b/Modules/Automation/Services/AutomationTriggerRegistry.php new file mode 100644 index 0000000..ed6f884 --- /dev/null +++ b/Modules/Automation/Services/AutomationTriggerRegistry.php @@ -0,0 +1,16 @@ +['label'=>'Manual','description'=>'Run from the administration area or API.'], + 'schedule'=>['label'=>'Schedule','description'=>'Run when next_run_at is due.'], + 'webhook'=>['label'=>'Incoming webhook','description'=>'Run from a signed API request.'], + 'domain_event'=>['label'=>'Domain event','description'=>'Run when an application event is dispatched.'], + ]; + } + public function has(string $type): bool { return array_key_exists($type,$this->all()); } +} diff --git a/Modules/Automation/Tests/Feature/ModuleBootTest.php b/Modules/Automation/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..cf6227a --- /dev/null +++ b/Modules/Automation/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,11 @@ +assertTrue(class_exists(\Modules\Automation\Providers\AutomationServiceProvider::class)); + $this->assertTrue(app()->bound(\Modules\Automation\Contracts\ActionRegistry::class)); + } +} diff --git a/Modules/Automation/module.json b/Modules/Automation/module.json new file mode 100644 index 0000000..c39c7ae --- /dev/null +++ b/Modules/Automation/module.json @@ -0,0 +1 @@ +{"name":"Automation","alias":"automation","version":"1.0.0","description":"Event-driven and scheduled automations with reusable triggers, actions, runs and execution logs.","keywords":["automation","events","triggers","actions","scheduler"],"priority":130,"providers":["Modules\\Automation\\Providers\\AutomationServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Audit","Api"]} diff --git a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php index d4700d2..3942bc8 100644 --- a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php +++ b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php @@ -70,6 +70,9 @@ final class RolePermissionSeeder extends Seeder 'security.view', 'security.manage', 'security.audit', + 'automation.view', + 'automation.manage', + 'automation.execute', ]; foreach ($permissions as $permission) { diff --git a/modules_statuses.json b/modules_statuses.json index bbc0218..d79fc6d 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -38,5 +38,6 @@ "Api": true, "Enterprise": true, "Commerce": true, - "Security": true + "Security": true, + "Automation": true } diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php index 86a2a2f..665661c 100644 --- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php +++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php @@ -37,6 +37,7 @@ {{ __('Enterprise Services') }}{{ __('Sites') }}{{ __('Dashboard') }}{{ __('Administration') }} {{ __('API & Webhooks') }}{{ __('Developer Tools') }} +{{ __('Automation') }} {{ __('Review Queue') }} {{ __('Users') }}{{ __('Roles & Permissions') }}{{ __('Media') }}{{ __('Search') }} {{ __('Localisation') }}{{ __('SEO') }}{{ __('Settings') }}{{ __('Modules') }}{{ __('Audit Log') }}