289 lines
10 KiB
PHP
289 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\SystemSettings;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\AiHealingLog;
|
|
use App\Services\SystemConfig\SystemConfigService;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class AiSelfHealingController extends Controller
|
|
{
|
|
public function index(SystemConfigService $systemConfig)
|
|
{
|
|
$this->authorize('view ai self-healing');
|
|
$grouped = $systemConfig->grouped();
|
|
$settings = $grouped['ai_healing'] ?? [];
|
|
$aiCfg = $grouped['ai_config'] ?? [];
|
|
|
|
$logs = AiHealingLog::latest()->take(100)->get();
|
|
$stats = $this->buildStats();
|
|
|
|
$topExceptions = AiHealingLog::select('error_type', DB::raw('COUNT(*) as total'))
|
|
->groupBy('error_type')
|
|
->orderByDesc('total')
|
|
->take(5)
|
|
->get()
|
|
->map(fn ($r) => [
|
|
'type' => class_basename($r->error_type),
|
|
'fqcn' => $r->error_type,
|
|
'count' => (int) $r->getAttribute('total'),
|
|
])
|
|
->all();
|
|
|
|
$timeline = $this->build24hTimeline();
|
|
$lastIncident = AiHealingLog::latest()->value('created_at');
|
|
|
|
$providerInfo = [
|
|
'enabled' => (bool) ($aiCfg['ai_enabled'] ?? false),
|
|
'provider' => strtoupper($aiCfg['ai_provider'] ?? 'gpt'),
|
|
'has_key' => $this->resolveProviderKeyPresent($aiCfg),
|
|
];
|
|
|
|
return view('pages.system_settings.ai-self-healing', compact(
|
|
'logs', 'settings', 'stats', 'topExceptions', 'timeline', 'lastIncident', 'providerInfo'
|
|
));
|
|
}
|
|
|
|
public function update(Request $request, SystemConfigService $systemConfig)
|
|
{
|
|
$this->authorize('manage ai self-healing');
|
|
$input = [
|
|
'ai_healing_enabled' => $request->has('ai_healing_enabled'),
|
|
'ai_healing_allow_cache' => $request->has('ai_healing_allow_cache'),
|
|
'ai_healing_allow_queue' => $request->has('ai_healing_allow_queue'),
|
|
'ai_healing_allow_maintenance' => $request->has('ai_healing_allow_maintenance'),
|
|
'ai_healing_allow_db' => $request->has('ai_healing_allow_db'),
|
|
'ai_healing_max_attempts_per_hour' => $request->input('ai_healing_max_attempts_per_hour', 5),
|
|
];
|
|
|
|
$systemConfig->update($input, [], auth()->id(), $request);
|
|
|
|
if ($request->wantsJson() || $request->ajax()) {
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'AI Healing configuration saved successfully.',
|
|
'settings' => $input,
|
|
]);
|
|
}
|
|
|
|
return redirect()->back()->with('success', 'AI Healing configuration saved successfully.');
|
|
}
|
|
|
|
public function show($id)
|
|
{
|
|
$this->authorize('view ai self-healing');
|
|
$log = AiHealingLog::findOrFail($id);
|
|
|
|
// Parse the action_taken marker for human-readable display + rollback info
|
|
$action = (string) $log->action_taken;
|
|
$actionPretty = $action;
|
|
$rollbackInfo = null;
|
|
|
|
if (str_starts_with($action, 'CODE_EDIT|')) {
|
|
$parts = explode('|', $action, 3);
|
|
$filePath = $parts[1] ?? '';
|
|
$backupPath = $parts[2] ?? '';
|
|
$backupExists = $backupPath && file_exists($backupPath);
|
|
$actionPretty = 'Code edit applied in ' . basename($filePath)
|
|
. ($backupExists ? ' (backup available)' : ' (backup missing)');
|
|
$rollbackInfo = [
|
|
'available' => $backupExists,
|
|
'file' => basename($filePath),
|
|
'backup_file' => basename($backupPath),
|
|
];
|
|
} elseif (str_starts_with($action, 'ROLLED_BACK|')) {
|
|
$parts = explode('|', $action, 3);
|
|
$filePath = $parts[1] ?? '';
|
|
$actionPretty = 'Rolled back: ' . basename($filePath) . ' restored from backup';
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => [
|
|
'id' => $log->id,
|
|
'error_type' => $log->error_type,
|
|
'error_message' => $log->error_message,
|
|
'stack_trace' => $log->stack_trace,
|
|
'ai_diagnosis' => $log->ai_diagnosis,
|
|
'action_taken' => $actionPretty,
|
|
'action_raw' => $action,
|
|
'rollback' => $rollbackInfo,
|
|
'status' => $log->status,
|
|
'original_code' => $log->original_code,
|
|
'fixed_code' => $log->fixed_code,
|
|
'created_at' => optional($log->created_at)->format('d M Y, H:i:s'),
|
|
'relative_time' => optional($log->created_at)->diffForHumans(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function clearLogs()
|
|
{
|
|
$this->authorize('manage ai self-healing');
|
|
AiHealingLog::truncate();
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'All AI Healing logs have been cleared successfully.',
|
|
]);
|
|
}
|
|
|
|
public function retry($id)
|
|
{
|
|
$this->authorize('manage ai self-healing');
|
|
$log = AiHealingLog::findOrFail($id);
|
|
|
|
if (!\in_array($log->status, ['failed', 'diagnosing', 'pending'], true)) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Only failed, pending, or stuck-diagnosing logs can be retried.',
|
|
], 422);
|
|
}
|
|
|
|
$log->update([
|
|
'status' => 'pending',
|
|
'action_taken' => 'Retry queued by ' . (auth()->user()->name ?? 'system'),
|
|
]);
|
|
|
|
try {
|
|
// Synchronous dispatch — matches the original interceptor behaviour
|
|
// so the user sees the new outcome immediately on refresh.
|
|
\App\Jobs\AiHealerJob::dispatchSync($log->id);
|
|
} catch (\Throwable $e) {
|
|
$log->update([
|
|
'status' => 'failed',
|
|
'ai_diagnosis' => 'Retry crashed: ' . $e->getMessage(),
|
|
'action_taken' => 'Retry pipeline error',
|
|
]);
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Retry failed: ' . $e->getMessage(),
|
|
], 500);
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Healing re-attempted. Status: ' . $log->fresh()->status,
|
|
'status' => $log->fresh()->status,
|
|
]);
|
|
}
|
|
|
|
public function rollback($id)
|
|
{
|
|
$this->authorize('manage ai self-healing');
|
|
$log = AiHealingLog::findOrFail($id);
|
|
|
|
$action = (string) $log->action_taken;
|
|
if (!str_starts_with($action, 'CODE_EDIT|')) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'This incident has no code-edit backup to roll back.',
|
|
], 422);
|
|
}
|
|
|
|
$parts = explode('|', $action, 3);
|
|
$filePath = $parts[1] ?? null;
|
|
$backupPath = $parts[2] ?? null;
|
|
|
|
if (!$filePath || !$backupPath || !file_exists($backupPath)) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Backup file not found. It may have been deleted manually.',
|
|
], 404);
|
|
}
|
|
|
|
if (!is_writable($filePath)) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Target file is not writable.',
|
|
], 422);
|
|
}
|
|
|
|
$backupContent = file_get_contents($backupPath);
|
|
file_put_contents($filePath, $backupContent);
|
|
\Illuminate\Support\Facades\Artisan::call('optimize:clear');
|
|
|
|
$log->update([
|
|
'action_taken' => 'ROLLED_BACK|' . $filePath . '|from ' . basename($backupPath),
|
|
'ai_diagnosis' => '[rolled back by ' . (auth()->user()->name ?? 'system') . '] ' . $log->ai_diagnosis,
|
|
]);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Rollback completed. ' . basename($filePath) . ' restored from backup.',
|
|
]);
|
|
}
|
|
|
|
public function stats()
|
|
{
|
|
$this->authorize('view ai self-healing');
|
|
return response()->json($this->buildStats() + [
|
|
'timeline' => $this->build24hTimeline(),
|
|
'last_incident' => optional(AiHealingLog::latest()->value('created_at'))->diffForHumans() ?? 'No incidents',
|
|
]);
|
|
}
|
|
|
|
private function buildStats(): array
|
|
{
|
|
$total = AiHealingLog::count();
|
|
$resolved = AiHealingLog::where('status', 'resolved')->count();
|
|
$failed = AiHealingLog::where('status', 'failed')->count();
|
|
$pending = AiHealingLog::whereIn('status', ['pending', 'diagnosing'])->count();
|
|
|
|
return [
|
|
'total' => $total,
|
|
'resolved' => $resolved,
|
|
'failed' => $failed,
|
|
'pending' => $pending,
|
|
'rate' => $total > 0 ? round(($resolved / $total) * 100) : 0,
|
|
'last_24h' => AiHealingLog::where('created_at', '>=', now()->subDay())->count(),
|
|
];
|
|
}
|
|
|
|
private function build24hTimeline(): array
|
|
{
|
|
$from = now()->subHours(23)->startOfHour();
|
|
|
|
$buckets = [];
|
|
for ($i = 23; $i >= 0; $i--) {
|
|
$hour = now()->subHours($i)->startOfHour();
|
|
$buckets[$hour->format('Y-m-d H')] = [
|
|
'label' => $hour->format('H:00'),
|
|
'count' => 0,
|
|
];
|
|
}
|
|
|
|
AiHealingLog::where('created_at', '>=', $from)
|
|
->select('created_at')
|
|
->orderBy('created_at')
|
|
->get()
|
|
->each(function ($row) use (&$buckets) {
|
|
$key = Carbon::parse($row->created_at)->startOfHour()->format('Y-m-d H');
|
|
if (isset($buckets[$key])) {
|
|
$buckets[$key]['count']++;
|
|
}
|
|
});
|
|
|
|
return array_values($buckets);
|
|
}
|
|
|
|
private function resolveProviderKeyPresent(array $aiCfg): bool
|
|
{
|
|
$provider = $aiCfg['ai_provider'] ?? 'gpt';
|
|
$keyMap = [
|
|
'gpt' => 'ai_gpt_key',
|
|
'gemini' => 'ai_gemini_key',
|
|
'claude' => 'ai_claude_key',
|
|
'deepseek' => 'ai_deepseek_key',
|
|
'grok' => 'ai_grok_key',
|
|
'mistral' => 'ai_mistral_key',
|
|
'openrouter' => 'ai_openrouter_key',
|
|
];
|
|
|
|
$field = $keyMap[$provider] ?? null;
|
|
return $field !== null && !empty($aiCfg[$field] ?? '');
|
|
}
|
|
}
|