|null */ public ?array $lastManifestPreview = null; /** @var array|null */ public ?array $latestBackupSummary = null; public ?string $lastPlannedUpdateSummary = null; public bool $updateInProgress = false; public ?string $updateJobId = null; public int $updateProgressPercent = 0; public string $updateProgressMessage = 'In attesa'; public string $updateProgressStatus = 'idle'; public string $gitRemote = 'origin'; public string $gitBranch = 'main'; public bool $gitForce = false; public ?string $gitWorkspacePath = null; public ?string $gitCurrentBranch = null; public ?string $gitCurrentCommit = null; public ?string $gitCurrentCommitDate = null; public ?string $gitSourceCommit = null; public ?string $gitSourceCommitDate = null; public ?string $gitRemoteCommit = null; public ?string $gitRemoteCommitDate = null; public bool $gitStatusUsesSyncSummary = false; public bool $gitWorkingTreeDirty = false; public string $gitAheadBehind = '-'; public int $gitAheadCount = 0; public int $gitBehindCount = 0; public bool $gitSyncInProgress = false; public ?string $gitSyncJobId = null; public int $gitSyncProgressPercent = 0; public string $gitSyncProgressMessage = 'In attesa'; public string $gitSyncProgressStatus = 'idle'; public ?int $lastGitSyncExitCode = null; public ?string $lastGitSyncOutput = null; public ?string $lastGitSyncAt = null; public ?string $lastGitSyncIssue = null; public ?string $lastGitDocsSyncAt = null; public ?string $developmentSnapshotHtml = null; public ?string $developmentSnapshotPath = null; public ?string $developmentSnapshotGeneratedAt = null; public string $activeTab = 'manuale'; public ?string $selectedCommitHash = null; public string $newCommitNote = ''; public string $bugFilterStatus = 'all'; public int $openBugCount = 0; public int $resolvedBugCount = 0; public ?string $selectedBugFingerprint = null; public string $bugResolutionNote = ''; public string $manualBugTitle = ''; public string $manualBugDetails = ''; /** @var array */ public array $bugRegistry = []; public int $nextBugId = 1; /** @var array */ public array $runtimeErrors = []; /** @var array */ public array $commitNotes = []; /** @var array */ public array $latestCommits = []; /** @var array */ public array $pendingGitCommits = []; public int $pendingGitCommitCount = 0; /** @var array */ public array $pendingGitChangedAreas = []; /** @var array */ public array $recentFilamentPages = []; /** @var array */ public array $functionalHighlights = []; /** @var array */ public array $supportUpdateEntries = []; public int $supportUpdateVisibleCount = 0; public int $supportImplementationCount = 0; public int $supportBugfixCount = 0; public int $supportDebugCount = 0; public ?int $supportUpdateFormId = null; public string $supportUpdateFormCategory = 'implementazione'; public string $supportUpdateFormAudience = 'utenti'; public string $supportUpdateFormTitle = ''; public string $supportUpdateFormSummary = ''; public string $supportUpdateFormImpact = ''; public string $supportUpdateFormPageLabel = ''; public string $supportUpdateFormPageClass = ''; public string $supportUpdateFormRelatedPath = ''; public string $supportUpdateFormRelatedCommit = ''; public string $supportUpdateFormRecordedAt = ''; public bool $supportUpdateFormVisible = true; public bool $supportUpdateFormHighlighted = false; public ?string $pendingGitPreviewIssue = null; /** @var array|null */ public ?array $lastNotificationSanitization = null; public bool $ticketAttachmentMetadataReady = false; /** @var array */ public array $manualSections = []; public function mount(): void { $this->reloadDashboardData(); } public static function canAccess(): bool { $user = Auth::user(); if (! $user) { return false; } // Pagina informativa: accessibile a chi accede al pannello. return true; } public function canRunUpdate(): bool { $user = Auth::user(); return is_object($user) && method_exists($user, 'hasAnyRole') && $user->hasAnyRole(['super-admin', 'admin', 'amministratore']); } public function reloadDashboardData(): void { $this->loadVersionInfo(); $this->loadLatestCommits(); $this->loadCommitNotes(); $this->loadRecentFilamentPages(); $this->loadFunctionalHighlights(); $this->loadManualSections(); $this->loadDevelopmentSnapshot(); $this->loadGitWorkspaceStatus(); $this->loadPendingGitPreview(); $this->loadSupportUpdateEntries(); $this->loadRuntimeErrors(); $this->loadLatestBackupSummary(); $this->loadLastNotificationSanitization(); $this->loadAttachmentMetadataStatus(); if ($this->supportUpdateFormRecordedAt === '') { $this->resetSupportUpdateForm(); } if ($this->selectedCommitHash === null && isset($this->latestCommits[0]['hash'])) { $this->selectedCommitHash = (string) $this->latestCommits[0]['hash']; } if ($this->selectedCommitHash !== null) { $this->newCommitNote = (string) ($this->commitNotes[$this->selectedCommitHash] ?? ''); } } public function runUpdate(): void { if (! $this->canRunUpdate()) { Notification::make() ->title('Permessi insufficienti') ->body('Solo super-admin/admin/amministratore possono lanciare gli aggiornamenti.') ->danger() ->send(); return; } $this->startUpdateJob(false); } public function runUpdateFallback(): void { if (! $this->canRunUpdate()) { Notification::make() ->title('Permessi insufficienti') ->body('Solo super-admin/admin/amministratore possono lanciare gli aggiornamenti.') ->danger() ->send(); return; } $this->startUpdateJob(true); } public function runGitSync(): void { if (! $this->canRunUpdate()) { Notification::make() ->title('Permessi insufficienti') ->body('Solo super-admin/admin/amministratore possono lanciare la sync da Gitea.') ->danger() ->send(); return; } $this->startGitSyncJob(); } public function runNodeRefresh(): void { if (! $this->canRunUpdate()) { Notification::make() ->title('Permessi insufficienti') ->body('Solo super-admin/admin/amministratore possono lanciare il refresh completo del nodo.') ->danger() ->send(); return; } $this->startNodeRefreshJob(); } public function runNodeRefreshFallback(): void { if (! $this->canRunUpdate()) { Notification::make() ->title('Permessi insufficienti') ->body('Solo super-admin/admin/amministratore possono lanciare il refresh completo del nodo.') ->danger() ->send(); return; } $this->startNodeRefreshJob(true); } public function generateDevelopmentSnapshot(): void { if (! $this->canRunUpdate()) { Notification::make() ->title('Permessi insufficienti') ->body('Solo super-admin/admin/amministratore possono rigenerare la snapshot di sviluppo.') ->danger() ->send(); return; } $exitCode = Artisan::call('netgescon:development-snapshot'); if ($exitCode !== 0) { Notification::make() ->title('Errore snapshot sviluppo') ->body(trim((string) Artisan::output()) ?: 'Rigenerazione non riuscita.') ->danger() ->send(); return; } $this->reloadDashboardData(); Notification::make() ->title('Snapshot sviluppo aggiornata') ->body('Il riepilogo di continuita e stato rigenerato per questa macchina.') ->success() ->send(); } public function refreshUpdateProgress(): void { $jobId = trim((string) ($this->updateJobId ?? '')); if ($jobId === '') { return; } $progressPath = storage_path('app/support/update-jobs/' . $jobId . '.progress.json'); $logPath = storage_path('app/support/update-jobs/' . $jobId . '.log'); if (is_file($progressPath)) { $decoded = json_decode((string) @file_get_contents($progressPath), true); if (is_array($decoded)) { $this->updateProgressPercent = (int) ($decoded['percent'] ?? $this->updateProgressPercent); $this->updateProgressMessage = (string) ($decoded['message'] ?? $this->updateProgressMessage); $this->updateProgressStatus = (string) ($decoded['status'] ?? $this->updateProgressStatus); if (in_array($this->updateProgressStatus, ['completed', 'failed'], true)) { $exitCode = isset($decoded['exit_code']) && is_numeric($decoded['exit_code']) ? (int) $decoded['exit_code'] : ($this->updateProgressStatus === 'completed' ? 0 : 1); $this->lastUpdateExitCode = $exitCode; $this->lastUpdateAt = now()->format('d/m/Y H:i:s'); $this->lastUpdateOutput = is_file($logPath) ? trim((string) @file_get_contents($logPath)) : ($this->updateProgressMessage ?: ''); $this->lastUpdateIssue = $this->detectUpdateIssue((string) ($this->lastUpdateOutput ?? ''), $exitCode); $this->updateInProgress = false; if ($exitCode !== 0) { $this->appendSupportEvent('update_async', 'Aggiornamento asincrono fallito', (string) ($this->lastUpdateOutput ?? '')); } $this->reloadDashboardData(); } } } } public function refreshGitSyncProgress(): void { $jobId = trim((string) ($this->gitSyncJobId ?? '')); if ($jobId === '') { return; } $progressPath = storage_path('app/support/git-sync-jobs/' . $jobId . '.progress.json'); $logPath = storage_path('app/support/git-sync-jobs/' . $jobId . '.log'); if (! is_file($progressPath)) { return; } $decoded = json_decode((string) @file_get_contents($progressPath), true); if (! is_array($decoded)) { return; } $this->gitSyncProgressPercent = (int) ($decoded['percent'] ?? $this->gitSyncProgressPercent); $this->gitSyncProgressMessage = (string) ($decoded['message'] ?? $this->gitSyncProgressMessage); $this->gitSyncProgressStatus = (string) ($decoded['status'] ?? $this->gitSyncProgressStatus); if (! in_array($this->gitSyncProgressStatus, ['completed', 'failed'], true)) { return; } $exitCode = isset($decoded['exit_code']) && is_numeric($decoded['exit_code']) ? (int) $decoded['exit_code'] : ($this->gitSyncProgressStatus === 'completed' ? 0 : 1); $this->lastGitSyncExitCode = $exitCode; $this->lastGitSyncAt = now()->format('d/m/Y H:i:s'); $this->lastGitSyncOutput = is_file($logPath) ? trim((string) @file_get_contents($logPath)) : ($this->gitSyncProgressMessage ?: ''); $this->lastGitSyncIssue = $exitCode === 0 ? null : $this->detectGitSyncIssue((string) ($this->lastGitSyncOutput ?? '')); $this->gitSyncInProgress = false; if (is_file(storage_path('app/support/generated/git-sync-last.json'))) { $summary = json_decode((string) @file_get_contents(storage_path('app/support/generated/git-sync-last.json')), true); if (is_array($summary) && isset($summary['synced_at'])) { $this->lastGitDocsSyncAt = (string) $summary['synced_at']; } } $this->reloadDashboardData(); } public function runMaintenanceOptimizeClear(): void { if (! $this->canRunUpdate()) { Notification::make()->title('Permessi insufficienti')->danger()->send(); return; } $result = Process::path(base_path())->timeout(180)->run(['php', 'artisan', 'optimize:clear']); $out = trim((string) ($result->output() !== '' ? $result->output() : $result->errorOutput())); $this->lastUpdateAt = now()->format('d/m/Y H:i:s'); $this->lastUpdateExitCode = $result->exitCode(); $this->lastUpdateOutput = 'MANUTENZIONE: optimize:clear' . PHP_EOL . $out; $this->lastUpdateIssue = $result->successful() ? null : 'Errore durante optimize:clear.'; if (! $result->successful()) { $this->appendSupportEvent('maintenance', 'optimize:clear fallito', $out); } Notification::make() ->title($result->successful() ? 'Cache pulite' : 'Errore pulizia cache') ->{$result->successful() ? 'success' : 'danger'}() ->send(); } public function runMaintenanceViewRebuild(): void { if (! $this->canRunUpdate()) { Notification::make()->title('Permessi insufficienti')->danger()->send(); return; } $clear = Process::path(base_path())->timeout(180)->run(['php', 'artisan', 'view:clear']); $cache = Process::path(base_path())->timeout(180)->run(['php', 'artisan', 'view:cache']); $ok = $clear->successful() && $cache->successful(); $out = trim(implode(PHP_EOL . PHP_EOL, array_filter([ 'MANUTENZIONE: view:clear', trim((string) ($clear->output() !== '' ? $clear->output() : $clear->errorOutput())), 'MANUTENZIONE: view:cache', trim((string) ($cache->output() !== '' ? $cache->output() : $cache->errorOutput())), ], fn(string $v): bool => $v !== ''))); $this->lastUpdateAt = now()->format('d/m/Y H:i:s'); $this->lastUpdateExitCode = $ok ? 0 : 1; $this->lastUpdateOutput = $out; $this->lastUpdateIssue = $ok ? null : 'Errore durante rebuild delle view Blade.'; if (! $ok) { $this->appendSupportEvent('maintenance', 'view rebuild fallito', $out); } Notification::make() ->title($ok ? 'View ricostruite' : 'Errore rebuild view') ->{$ok ? 'success' : 'danger'}() ->send(); } public function runMaintenanceMigrate(): void { if (! $this->canRunUpdate()) { Notification::make()->title('Permessi insufficienti')->danger()->send(); return; } $result = Process::path(base_path())->timeout(600)->run(['php', 'artisan', 'migrate', '--force']); $out = trim((string) ($result->output() !== '' ? $result->output() : $result->errorOutput())); $this->lastUpdateAt = now()->format('d/m/Y H:i:s'); $this->lastUpdateExitCode = $result->exitCode(); $this->lastUpdateOutput = 'MANUTENZIONE: migrate --force' . PHP_EOL . $out; $this->lastUpdateIssue = $result->successful() ? null : 'Errore durante migrate --force.'; if (! $result->successful()) { $this->appendSupportEvent('maintenance', 'migrate fallita', $out); } Notification::make() ->title($result->successful() ? 'Migration applicate' : 'Errore migrate') ->{$result->successful() ? 'success' : 'danger'}() ->send(); } /** * @return array */ public function getUpdatePlannedStepsProperty(): array { $requireDrive = $this->shouldRequireDrivePreupdateBackup(); $driveEnabled = $this->shouldAttemptDrivePreupdateBackup(); $steps = [ 'Verifica canale update: ' . $this->updateChannel, $this->updateSkipBackup ? 'Backup pre-update saltato manualmente (modalita test/debug)' : 'Backup pre-update automatico (snapshot differenziale + indice record)', $this->updateSkipBackup ? 'Upload backup su Google Drive non eseguito perche il backup e stato saltato' : ($requireDrive ? 'Upload backup su Google Drive obbligatorio prima dell update' : ($driveEnabled ? 'Upload backup su Google Drive tentato se configurato sul sito corrente' : 'Upload backup su Google Drive non obbligatorio per questo nodo')), 'Esecuzione in modalita ' . ($this->updateDryRun ? 'dry-run (nessuna scrittura)' : 'apply (aggiornamento reale)'), 'Flag force: ' . ($this->updateForce ? 'abilitato' : 'disabilitato'), 'Check endpoint update consigliato prima del lancio', 'Aggiornamento versione e ricarica dashboard a fine procedura', ]; return $steps; } public function runUpdateConnectivityCheck(): void { $host = 'updates.netgescon.it'; $url = 'https://updates.netgescon.it/api/v1/distribution/updates/manifest?channel=' . $this->updateChannel; $resolveArg = ''; $resolveRaw = trim((string) config('distribution.http_resolve', '')); if ($resolveRaw !== '') { $entries = array_filter(array_map('trim', explode(',', $resolveRaw))); foreach ($entries as $entry) { if (str_starts_with($entry, $host . ':')) { $resolveArg = ' --resolve ' . escapeshellarg($entry); break; } } } $dnsResult = Process::path(base_path())->run('getent hosts ' . escapeshellarg($host)); $headResult = Process::path(base_path())->run('curl -I -sS --max-time 12' . $resolveArg . ' ' . escapeshellarg($url)); $out = []; $out[] = 'DNS:'; $out[] = trim($dnsResult->output() !== '' ? $dnsResult->output() : $dnsResult->errorOutput()); $out[] = ''; $out[] = 'HTTP HEAD:'; if ($resolveArg !== '') { $out[] = '(resolve override attivo da NETGESCON_UPDATE_RESOLVE)'; } $out[] = trim($headResult->output() !== '' ? $headResult->output() : $headResult->errorOutput()); $this->lastConnectivityCheckOutput = trim(implode(PHP_EOL, $out)); $this->lastManifestPreview = null; $combined = Str::lower($this->lastConnectivityCheckOutput); if (str_contains($combined, 'timed out') || str_contains($combined, 'curl: (28)')) { $this->appendSupportEvent('update_connectivity', 'Timeout endpoint updates', $this->lastConnectivityCheckOutput); Notification::make() ->title('Check con timeout') ->warning() ->body('Endpoint update non raggiungibile entro timeout.') ->send(); return; } try { $manifestUrl = rtrim((string) config('distribution.update_url', 'https://updates.netgescon.it'), '/') . '/api/v1/distribution/updates/manifest'; $response = Http::timeout((int) config('distribution.http_timeout_seconds', 60)) ->withOptions($this->buildDistributionHttpOptions()) ->acceptJson() ->get($manifestUrl, ['channel' => $this->updateChannel]); if ($response->successful()) { $manifest = $response->json(); if (is_array($manifest)) { $this->lastManifestPreview = [ 'version' => (string) ($manifest['version'] ?? '-'), 'package_url' => (string) ($manifest['package_url'] ?? '-'), 'sha256' => (string) ($manifest['sha256'] ?? '-'), 'migrate_required' => (bool) ($manifest['migrate_required'] ?? false), 'release_notes' => (string) ($manifest['release_notes'] ?? $manifest['notes'] ?? ''), 'security' => $manifest['security'] ?? $manifest['security_updates'] ?? null, ]; } } } catch (Throwable) { } Notification::make() ->title('Check connettivita completato') ->success() ->send(); } private function loadVersionInfo(): void { $versionFile = base_path('VERSION'); if (File::exists($versionFile)) { $fileVersion = trim((string) File::get($versionFile)); if ($fileVersion !== '') { $this->currentVersion = $fileVersion; $this->versionSource = 'VERSION'; return; } } $this->currentVersion = (string) config('netgescon.version', '0.0.0'); $this->versionSource = 'config'; } private function loadLatestCommits(): void { $this->latestCommits = []; $repoPath = $this->resolveGitWorkspacePath(); if ($repoPath === null) { return; } $format = '%H|%h|%an|%ad|%s'; $command = 'git log -n 20 --date=format:"%d/%m/%Y %H:%M" --pretty=format:"' . $format . '"'; $result = Process::path($repoPath)->run($command); if (! $result->successful()) { return; } $lines = preg_split('/\r\n|\r|\n/', trim($result->output())) ?: []; foreach ($lines as $line) { $parts = explode('|', $line, 5); if (count($parts) !== 5) { continue; } $this->latestCommits[] = [ 'hash' => (string) $parts[0], 'short_hash' => (string) $parts[1], 'author' => (string) $parts[2], 'date' => (string) $parts[3], 'message' => (string) $parts[4], ]; } } private function loadPendingGitPreview(): void { $this->pendingGitCommits = []; $this->pendingGitCommitCount = 0; $this->pendingGitChangedAreas = []; $this->pendingGitPreviewIssue = null; $remote = trim($this->gitRemote); $branch = trim($this->gitBranch); if ($remote === '' || $branch === '') { return; } $repoPath = $this->resolveGitWorkspacePath(); if ($repoPath === null) { $this->pendingGitPreviewIssue = 'Repository Git non disponibile su questo nodo. Il conteggio aggiornamenti verra mostrato dopo una sync valida o se il repository sorgente e configurato.'; return; } $fetch = Process::path($repoPath) ->timeout(120) ->run(['git', 'fetch', $remote, $branch, '--quiet']); if (! $fetch->successful()) { $this->pendingGitPreviewIssue = 'Impossibile aggiornare l anteprima remota da Gitea. Il conteggio mostrato potrebbe essere non aggiornato.'; } $baseRef = $this->gitStatusUsesSyncSummary && filled($this->gitCurrentCommit) ? (string) $this->gitCurrentCommit : 'HEAD'; $range = $baseRef . '..refs/remotes/' . $remote . '/' . $branch; $count = $this->runGitProcess(['rev-list', '--count', $range]); if (! is_string($count) || ! ctype_digit(trim($count))) { return; } $this->pendingGitCommitCount = (int) trim($count); if ($this->pendingGitCommitCount <= 0) { return; } $format = '%H|%h|%an|%ad|%s'; $command = 'git log -n 12 --date=format:"%d/%m/%Y %H:%M" --pretty=format:"' . $format . '" ' . escapeshellarg($range); $result = Process::path($repoPath)->run(['bash', '-lc', $command]); if ($result->successful()) { $lines = preg_split('/\r\n|\r|\n/', trim($result->output())) ?: []; foreach ($lines as $line) { $parts = explode('|', $line, 5); if (count($parts) !== 5) { continue; } $this->pendingGitCommits[] = [ 'hash' => (string) $parts[0], 'short_hash' => (string) $parts[1], 'author' => (string) $parts[2], 'date' => (string) $parts[3], 'message' => (string) $parts[4], ]; } } $changed = $this->runGitProcess([ 'diff', '--name-only', $range, '--', 'app/Filament/Pages', 'resources/views/filament/pages', 'app/Console/Commands', 'docs/support', ]); if (! is_string($changed) || trim($changed) === '') { return; } $seen = []; $lines = preg_split('/\r\n|\r|\n/', trim($changed)) ?: []; foreach ($lines as $path) { $path = trim((string) $path); if ($path === '' || isset($seen[$path])) { continue; } $seen[$path] = true; $this->pendingGitChangedAreas[] = [ 'label' => $this->humanizePagePath($path), 'path' => $path, 'area' => $this->resolveChangedAreaLabel($path), ]; if (count($this->pendingGitChangedAreas) >= 16) { break; } } } private function startUpdateJob(bool $fallback): void { $this->registerPlannedUpdateSummary(); $jobId = now()->format('YmdHis') . '-' . Str::lower(Str::random(6)); $jobsDir = storage_path('app/support/update-jobs'); if (! is_dir($jobsDir)) { @mkdir($jobsDir, 0775, true); } $progressPath = $jobsDir . '/' . $jobId . '.progress.json'; $logPath = $jobsDir . '/' . $jobId . '.log'; $this->updateJobId = $jobId; $this->updateInProgress = true; $this->updateProgressPercent = 1; $this->updateProgressMessage = $this->updateSkipBackup ? 'Preparazione aggiornamento senza backup (modalita test)...' : 'Preparazione backup pre-update...'; $this->updateProgressStatus = 'running'; @file_put_contents($progressPath, json_encode([ 'timestamp' => now()->toIso8601String(), 'percent' => 1, 'message' => $this->updateSkipBackup ? 'Preparazione aggiornamento senza backup (modalita test)...' : 'Preparazione backup pre-update...', 'status' => 'running', 'exit_code' => null, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $adminId = $this->resolveAmministratoreId(); $requireDrive = $this->shouldRequireDrivePreupdateBackup(); $driveEnabled = $this->shouldAttemptDrivePreupdateBackup(); if ($this->updateSkipBackup) { $requireDrive = false; $driveEnabled = false; } if ($requireDrive && $adminId <= 0) { $this->updateInProgress = false; $this->updateProgressStatus = 'failed'; $this->updateProgressPercent = 100; $this->updateProgressMessage = 'Amministratore Google non risolto'; $this->lastUpdateExitCode = 1; $this->lastUpdateOutput = 'Backup Google Drive obbligatorio: impossibile risolvere amministratore attivo per il sito corrente.'; $this->lastUpdateIssue = 'Aggiornamento bloccato: serve un amministratore attivo con integrazione Google Drive per creare la seconda copia ripristinabile.'; @file_put_contents($progressPath, json_encode([ 'timestamp' => now()->toIso8601String(), 'percent' => 100, 'message' => 'Amministratore Google non risolto', 'status' => 'failed', 'exit_code' => 1, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); return; } if (! $this->updateSkipBackup) { $backupParams = [ '--differential' => true, '--tag' => 'update-' . $jobId, ]; if ($driveEnabled && $adminId > 0) { $backupParams['--drive'] = true; $backupParams['--admin-id'] = (string) $adminId; if ($requireDrive) { $backupParams['--require-drive'] = true; } } try { $backupExit = Artisan::call('netgescon:preupdate-backup', $backupParams); $backupOut = trim((string) Artisan::output()); if ($backupExit !== 0) { $this->updateInProgress = false; $this->updateProgressStatus = 'failed'; $this->updateProgressPercent = 100; $this->updateProgressMessage = 'Backup pre-update fallito'; $this->lastUpdateExitCode = 1; $this->lastUpdateOutput = $backupOut; $this->lastUpdateIssue = 'Backup pre-update fallito: aggiornamento bloccato per sicurezza.'; @file_put_contents($progressPath, json_encode([ 'timestamp' => now()->toIso8601String(), 'percent' => 100, 'message' => 'Backup pre-update fallito', 'status' => 'failed', 'exit_code' => 1, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $this->loadLatestBackupSummary(); return; } $this->loadLatestBackupSummary(); } catch (Throwable $e) { $this->updateInProgress = false; $this->updateProgressStatus = 'failed'; $this->updateProgressPercent = 100; $this->updateProgressMessage = 'Eccezione backup pre-update'; $this->lastUpdateExitCode = 1; $this->lastUpdateOutput = $e->getMessage(); $this->lastUpdateIssue = 'Eccezione durante backup pre-update: aggiornamento bloccato.'; @file_put_contents($progressPath, json_encode([ 'timestamp' => now()->toIso8601String(), 'percent' => 100, 'message' => 'Eccezione backup pre-update', 'status' => 'failed', 'exit_code' => 1, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); return; } } else { $this->appendSupportEvent('update_skip_backup', 'Backup pre-update saltato', 'Modalita test/debug attivata da Supporto > Modifiche'); } $args = [ 'php', 'artisan', 'netgescon:update', '--channel=' . $this->updateChannel, '--apply', '--progress-file=' . $progressPath, ]; if ($this->updateDryRun) { $args[] = '--dry-run'; } if ($this->updateForce || $fallback) { $args[] = '--force'; } $escaped = implode(' ', array_map(static fn(string $arg): string => escapeshellarg($arg), $args)); $shell = 'nohup ' . $escaped . ' > ' . escapeshellarg($logPath) . ' 2>&1 & echo $!'; $started = Process::path(base_path())->run(['bash', '-lc', $shell]); if (! $started->successful()) { $this->updateInProgress = false; $this->updateProgressStatus = 'failed'; $this->updateProgressPercent = 100; $this->updateProgressMessage = 'Impossibile avviare job update'; $this->lastUpdateExitCode = 1; $this->lastUpdateOutput = trim((string) ($started->output() !== '' ? $started->output() : $started->errorOutput())); $this->lastUpdateIssue = 'Avvio job update fallito.'; return; } $this->updateProgressPercent = 5; $this->updateProgressMessage = 'Job update avviato (PID ' . trim($started->output()) . ')'; $this->updateProgressStatus = 'running'; Notification::make() ->title('Aggiornamento avviato') ->body($this->updateSkipBackup ? 'Aggiornamento avviato senza backup pre-update (modalita test). Avanzamento visibile in tempo reale.' : 'Backup pre-update completato. Avanzamento visibile in tempo reale.') ->success() ->send(); } private function resolveAmministratoreId(): int { $user = Auth::user(); if (! $user || ! method_exists($user, 'hasAnyRole')) { return 0; } $admin = $user->amministratore; if ($admin instanceof Amministratore) { return (int) $admin->id; } if ($user->hasAnyRole(['super-admin', 'admin'])) { $stabile = StabileContext::getActiveStabile($user); if ($stabile instanceof Stabile) { return (int) ($stabile->amministratore_id ?? 0); } } return 0; } private function shouldAttemptDrivePreupdateBackup(): bool { if (! (bool) config('distribution.preupdate_drive_enabled', false)) { return false; } $adminId = $this->resolveAmministratoreId(); if ($adminId <= 0) { return false; } $admin = Amministratore::query()->find($adminId); if (! $admin instanceof Amministratore) { return false; } $google = (array) data_get($admin->impostazioni ?? [], 'google', []); $oauth = (array) data_get($google, 'oauth', []); $accessToken = trim((string) ($oauth['access_token'] ?? '')); $refreshToken = trim((string) ($oauth['refresh_token'] ?? '')); return $accessToken !== '' || $refreshToken !== ''; } private function shouldRequireDrivePreupdateBackup(): bool { return (bool) config('distribution.preupdate_require_drive', false); } private function registerPlannedUpdateSummary(): void { $summary = implode(' | ', $this->getUpdatePlannedStepsProperty()); $this->lastPlannedUpdateSummary = $summary; $this->appendSupportEvent('update_plan', 'Piano aggiornamento registrato', $summary); } private function loadLatestBackupSummary(): void { $this->latestBackupSummary = null; $path = storage_path('app/update-backups/latest-manifest.json'); if (! is_file($path)) { return; } $decoded = json_decode((string) @file_get_contents($path), true); if (! is_array($decoded)) { return; } $zipPath = (string) ($decoded['zip_path'] ?? ''); $drive = is_array($decoded['drive'] ?? null) ? $decoded['drive'] : null; $this->latestBackupSummary = [ 'snapshot_id' => (string) ($decoded['snapshot_id'] ?? '-'), 'created_at' => (string) ($decoded['created_at'] ?? '-'), 'zip_path' => $zipPath, 'zip_exists' => $zipPath !== '' && is_file($zipPath), 'zip_size_mb' => $zipPath !== '' && is_file($zipPath) ? round(((float) filesize($zipPath)) / 1048576, 2) : null, 'drive' => $drive, 'copied_files' => (int) ($decoded['copied_files'] ?? 0), 'tables_count' => is_array($decoded['tables'] ?? null) ? count((array) $decoded['tables']) : 0, ]; } /** @return array */ private function buildDistributionHttpOptions(): array { $raw = trim((string) config('distribution.http_resolve', '')); if ($raw === '') { return []; } $entries = array_values(array_filter(array_map('trim', explode(',', $raw)), static fn(string $v): bool => $v !== '')); if ($entries === []) { return []; } return [ 'curl' => [ CURLOPT_RESOLVE => $entries, ], ]; } private function loadRecentFilamentPages(): void { $this->recentFilamentPages = []; $repoPath = $this->resolveGitWorkspacePath(); if ($repoPath === null) { return; } $command = 'git log -n 30 --name-only --pretty=format:"@@@%h|%s" -- app/Filament/Pages resources/views/filament/pages'; $result = Process::path($repoPath)->run($command); if (! $result->successful()) { return; } $currentCommit = null; $currentMessage = ''; $seen = []; $lines = preg_split('/\r\n|\r|\n/', (string) $result->output()) ?: []; foreach ($lines as $line) { $line = trim((string) $line); if ($line === '') { continue; } if (str_starts_with($line, '@@@')) { $parts = explode('|', substr($line, 3), 2); $currentCommit = trim((string) ($parts[0] ?? '')); $currentMessage = trim((string) ($parts[1] ?? '')); continue; } if ($currentCommit === null) { continue; } if (! str_starts_with($line, 'app/Filament/Pages/') && ! str_starts_with($line, 'resources/views/filament/pages/')) { continue; } $key = $line; if (isset($seen[$key])) { continue; } $seen[$key] = true; $this->recentFilamentPages[] = [ 'page' => $this->humanizePagePath($line), 'path' => $line, 'commit' => $currentCommit, 'message' => $currentMessage, ]; if (count($this->recentFilamentPages) >= 24) { break; } } } private function loadGitWorkspaceStatus(): void { $repoPath = $this->resolveGitWorkspacePath(); $this->gitWorkspacePath = $repoPath; $this->gitCurrentBranch = $this->runGitProcessAt($repoPath, ['rev-parse', '--abbrev-ref', 'HEAD']); $this->gitSourceCommit = $this->runGitProcessAt($repoPath, ['rev-parse', '--short', 'HEAD']); $this->gitSourceCommitDate = $this->runGitProcessAt($repoPath, ['show', '-s', '--date=format:%d/%m/%Y %H:%M', '--format=%cd', 'HEAD']); $this->gitCurrentCommit = $this->gitSourceCommit; $this->gitCurrentCommitDate = $this->gitSourceCommitDate; $this->gitRemoteCommit = $this->runGitProcessAt($repoPath, ['rev-parse', '--short', 'refs/remotes/' . $this->gitRemote . '/' . $this->gitBranch]); $this->gitRemoteCommitDate = $this->runGitProcessAt($repoPath, ['show', '-s', '--date=format:%d/%m/%Y %H:%M', '--format=%cd', 'refs/remotes/' . $this->gitRemote . '/' . $this->gitBranch]); $this->gitWorkingTreeDirty = trim((string) ($this->runGitProcessAt($repoPath, ['status', '--porcelain']) ?? '')) !== ''; $this->gitAheadCount = 0; $this->gitBehindCount = 0; $this->gitStatusUsesSyncSummary = false; $currentRef = 'HEAD'; $summaryPath = storage_path('app/support/generated/git-sync-last.json'); if (is_file($summaryPath)) { $summary = json_decode((string) @file_get_contents($summaryPath), true); if (is_array($summary)) { $summaryMode = (string) ($summary['mode'] ?? ''); $summaryStatus = (string) ($summary['status'] ?? ''); $summaryCurrentCommit = isset($summary['after_commit']) ? (string) $summary['after_commit'] : (isset($summary['before_commit']) ? (string) $summary['before_commit'] : ''); $summaryRemoteCommit = isset($summary['remote_commit']) ? (string) $summary['remote_commit'] : ''; $syncDate = isset($summary['synced_at']) ? (string) $summary['synced_at'] : null; $this->gitCurrentBranch = $this->gitCurrentBranch ?: (isset($summary['branch']) ? (string) $summary['branch'] : null); if ($summaryMode === 'external-repo-rsync' && $summaryStatus === 'completed' && $summaryCurrentCommit !== '') { $this->gitStatusUsesSyncSummary = true; $this->gitCurrentCommit = $summaryCurrentCommit; $this->gitCurrentCommitDate = $syncDate ?: $this->gitCurrentCommitDate; $this->gitRemoteCommit = $summaryRemoteCommit !== '' ? $summaryRemoteCommit : ($this->gitRemoteCommit ?: $summaryCurrentCommit); $this->gitRemoteCommitDate = $syncDate ?: $this->gitRemoteCommitDate; $currentRef = $summaryCurrentCommit; } else { $this->gitCurrentCommit = $this->gitCurrentCommit ?: ($summaryCurrentCommit !== '' ? $summaryCurrentCommit : null); $this->gitRemoteCommit = $this->gitRemoteCommit ?: ($summaryRemoteCommit !== '' ? $summaryRemoteCommit : $this->gitCurrentCommit); $this->gitCurrentCommitDate = $this->gitCurrentCommitDate ?: $syncDate; $this->gitRemoteCommitDate = $this->gitRemoteCommitDate ?: $syncDate; } $this->lastGitDocsSyncAt = isset($summary['synced_at']) ? (string) $summary['synced_at'] : $this->lastGitDocsSyncAt; $this->lastGitSyncAt = isset($summary['synced_at']) ? (string) $summary['synced_at'] : $this->lastGitSyncAt; $this->lastGitSyncExitCode = isset($summary['exit_code']) && is_numeric($summary['exit_code']) ? (int) $summary['exit_code'] : $this->lastGitSyncExitCode; $this->lastGitSyncIssue = isset($summary['status']) && (string) $summary['status'] === 'failed' ? (string) ($summary['message'] ?? $this->lastGitSyncIssue) : null; if ($repoPath === null && $this->pendingGitPreviewIssue === null) { $this->pendingGitPreviewIssue = 'Questo nodo sta leggendo i dati dall ultima sync registrata. Per vedere branch, commit e conteggi live serve configurare il repository Git sorgente sul server.'; } } } $aheadBehind = $this->runGitProcessAt($repoPath, ['rev-list', '--left-right', '--count', $currentRef . '...refs/remotes/' . $this->gitRemote . '/' . $this->gitBranch]); if (is_string($aheadBehind) && preg_match('/^(\d+)\s+(\d+)$/', trim($aheadBehind), $matches) === 1) { $this->gitAheadCount = (int) $matches[1]; $this->gitBehindCount = (int) $matches[2]; $this->gitAheadBehind = 'ahead ' . $this->gitAheadCount . ' / behind ' . $this->gitBehindCount; } else { $this->gitAheadBehind = $repoPath === null ? 'non disponibile' : '-'; } } private function loadSupportUpdateEntries(): void { $this->supportUpdateEntries = []; $this->supportUpdateVisibleCount = 0; $this->supportImplementationCount = 0; $this->supportBugfixCount = 0; $this->supportDebugCount = 0; if (! $this->isSupportUpdateRegistryReady()) { return; } $this->syncStructuredUpdateRegistry(); $visibleEntries = SupportUpdateEntry::query() ->where('is_visible', true) ->get(); $this->supportUpdateVisibleCount = $visibleEntries->count(); $this->supportImplementationCount = $visibleEntries->where('category', 'implementazione')->count(); $this->supportBugfixCount = $visibleEntries->where('category', 'bugfix')->count(); $this->supportDebugCount = $visibleEntries->where('category', 'debug')->count(); $entries = SupportUpdateEntry::query() ->where('is_visible', true) ->orderByDesc('is_highlighted') ->orderByDesc('recorded_at') ->orderByDesc('id') ->limit(24) ->get(); foreach ($entries as $entry) { $category = (string) $entry->category; $this->supportUpdateEntries[] = [ 'id' => (int) $entry->id, 'release_key' => (string) $entry->release_key, 'category' => $category, 'audience' => (string) $entry->audience, 'title' => (string) $entry->title, 'summary' => (string) $entry->summary, 'impact' => (string) ($entry->impact ?? ''), 'page_label' => (string) ($entry->page_label ?? ''), 'page_class' => (string) ($entry->page_class ?? ''), 'related_path' => (string) ($entry->related_path ?? ''), 'related_commit' => (string) ($entry->related_commit ?? ''), 'recorded_at' => (string) ($entry->recorded_at ?? ''), 'is_visible' => (bool) $entry->is_visible, 'is_highlighted' => (bool) $entry->is_highlighted, 'page_url' => $this->resolveSupportUpdatePageUrl((string) ($entry->page_class ?? '')), ]; } } private function loadLastNotificationSanitization(): void { $this->lastNotificationSanitization = null; $path = storage_path('app/support/filament-notification-sanitize.json'); if (! is_file($path)) { return; } $decoded = json_decode((string) @file_get_contents($path), true); if (! is_array($decoded)) { return; } $this->lastNotificationSanitization = $decoded; } private function loadAttachmentMetadataStatus(): void { try { $this->ticketAttachmentMetadataReady = Schema::hasTable('ticket_attachments') && Schema::hasColumn('ticket_attachments', 'metadata'); } catch (Throwable) { $this->ticketAttachmentMetadataReady = false; } } private function loadDevelopmentSnapshot(): void { $generatedPath = storage_path('app/support/generated/CONTINUITA-SVILUPPO-SNAPSHOT.md'); $jsonPath = storage_path('app/support/generated/CONTINUITA-SVILUPPO-SNAPSHOT.json'); $fallbackPath = base_path('docs/support/CONTINUITA-SVILUPPO-AGENT.md'); $path = is_file($generatedPath) ? $generatedPath : $fallbackPath; $raw = is_file($path) ? (string) @file_get_contents($path) : '# Snapshot sviluppo non disponibile' . PHP_EOL . PHP_EOL . '- Rigenera la snapshot dalla pagina Supporto > Modifiche.'; $this->developmentSnapshotPath = $path; $this->developmentSnapshotHtml = $this->renderSupportMarkdown($raw); $this->developmentSnapshotGeneratedAt = null; if (is_file($jsonPath)) { $decoded = json_decode((string) @file_get_contents($jsonPath), true); if (is_array($decoded) && isset($decoded['generated_at'])) { $this->developmentSnapshotGeneratedAt = (string) $decoded['generated_at']; } } } private function loadFunctionalHighlights(): void { $this->functionalHighlights = []; $path = base_path('docs/NETGESCON-MODIFICHE.md'); if (! is_file($path)) { return; } $lines = preg_split('/\r\n|\r|\n/', (string) @file_get_contents($path)) ?: []; foreach ($lines as $line) { $line = trim((string) $line); if ($line === '') { continue; } if (! str_starts_with($line, '-')) { continue; } if (! str_contains($line, '[U]') && ! str_contains($line, '[P]')) { continue; } $this->functionalHighlights[] = trim(ltrim($line, '- ')); if (count($this->functionalHighlights) >= 30) { break; } } } private function humanizePagePath(string $path): string { $base = basename($path); $name = preg_replace('/\.(php|blade\.php)$/', '', $base) ?? $base; $name = preg_replace('/(? $path !== '')); foreach ($candidates as $candidate) { if (! is_dir($candidate . '/.git')) { continue; } if ($this->runGitProcessAt($candidate, ['rev-parse', '--git-dir']) !== null) { return $candidate; } } return null; } private function runGitProcessAt(?string $repoPath, array $arguments): ?string { if ($repoPath === null) { return null; } $result = Process::path($repoPath) ->timeout(120) ->run(array_merge(['git', '-c', 'safe.directory=' . $repoPath], $arguments)); if (! $result->successful()) { return null; } $output = trim((string) $result->output()); return $output !== '' ? $output : null; } private function isSupportUpdateRegistryReady(): bool { try { return Schema::hasTable('support_update_entries'); } catch (Throwable) { return false; } } private function isSupportBugRegistryReady(): bool { try { return Schema::hasTable('support_bug_registries'); } catch (Throwable) { return false; } } private function syncStructuredUpdateRegistry(): void { foreach ($this->getStructuredUpdateDefinitions() as $row) { $exists = SupportUpdateEntry::query() ->where('release_key', (string) $row['release_key']) ->exists(); if ($exists) { continue; } $row['updated_at'] = now(); $row['created_at'] = now(); SupportUpdateEntry::query()->create($row); } } /** @return array> */ private function getStructuredUpdateDefinitions(): array { return [ [ 'release_key' => 'post-it-assegnazione-operativa', 'category' => 'implementazione', 'audience' => 'utenti', 'title' => 'Assegnazione Post-it a operatore, collaboratore o fornitore', 'summary' => 'La scheda Post-it registra ora l assegnazione operativa direttamente in UI e propaga il riferimento anche al ticket collegato.', 'impact' => 'Riduce passaggi manuali e mantiene il responsabile collegato al promemoria anche dopo la conversione in ticket.', 'page_label' => 'Strumenti > Post-it', 'page_class' => \App\Filament\Pages\Strumenti\PostIt::class, 'related_path' => 'app/Filament/Pages/Strumenti/PostIt.php', 'related_commit' => '236aef5', 'is_visible' => true, 'is_highlighted' => true, 'recorded_at' => '2026-04-04 09:30', ], [ 'release_key' => 'lavagna-chiamate-filtra-interni', 'category' => 'bugfix', 'audience' => 'utenti', 'title' => 'Lavagna chiamate e storico Post-it ripuliti dai numeri interni', 'summary' => 'Le chiamate interne restano nel flusso tecnico SMDR ma non inquinano piu la vista operativa della lavagna e dello storico.', 'impact' => 'Gli operatori vedono solo chiamate lavorabili e la vista tecnica conserva tutto l import PBX completo.', 'page_label' => 'Strumenti > Post-it Gestione', 'page_class' => \App\Filament\Pages\Strumenti\PostItGestione::class, 'related_path' => 'app/Filament/Pages/Strumenti/PostItGestione.php', 'related_commit' => 'ab7b804', 'is_visible' => true, 'is_highlighted' => true, 'recorded_at' => '2026-04-04 10:00', ], [ 'release_key' => 'lavagna-archivio-modal-raggruppata', 'category' => 'implementazione', 'audience' => 'utenti', 'title' => 'Lavagna gialla con archivio chiusi e dettaglio storico per giorno', 'summary' => 'Le chiamate raggruppate sono state spostate in una tab dedicata con box uniformi, archivio dei chiusi e modal storico per giornate.', 'impact' => 'La lettura diventa piu vicina alla bacheca reale e lo storico resta consultabile senza perdere il collegamento con rubrica e nominativo.', 'page_label' => 'Strumenti > Post-it Gestione', 'page_class' => \App\Filament\Pages\Strumenti\PostItGestione::class, 'related_path' => 'resources/views/filament/pages/strumenti/post-it-gestione.blade.php', 'related_commit' => 'b9b9e99', 'is_visible' => true, 'is_highlighted' => true, 'recorded_at' => '2026-04-04 10:20', ], [ 'release_key' => 'pbx-mappature-osservate-collaboratori', 'category' => 'implementazione', 'audience' => 'supporto', 'title' => 'Scheda amministratore con mapping PBX osservato per collaboratori', 'summary' => 'La configurazione PBX/TAPI accumula token osservati e permette mappature multiple per interni, gruppi, linee e flag click to call/popup.', 'impact' => 'Riduce configurazioni cieche e rende il PBX piu neutro rispetto al fornitore, mantenendo traccia operativa dei valori visti.', 'page_label' => 'Impostazioni > Scheda Amministratore', 'page_class' => \App\Filament\Pages\Impostazioni\SchedaAmministratore::class, 'related_path' => 'app/Filament/Pages/Impostazioni/SchedaAmministratore.php', 'related_commit' => '236aef5', 'is_visible' => true, 'is_highlighted' => false, 'recorded_at' => '2026-04-04 10:35', ], [ 'release_key' => 'sync-git-rubrica-qa-sicura', 'category' => 'debug', 'audience' => 'supporto', 'title' => 'Sync Git da Gitea con ricucitura sicura dei legami rubrica', 'summary' => 'La sync UI prova anche gescon:qa-rubrica-legami --apply-safe, cosi il riallineamento staging resta dentro il flusso applicativo.', 'impact' => 'Riduce dipendenza dal terminale e abbassa il rischio di dimenticare la ricostruzione minima della rubrica dopo il pull.', 'page_label' => 'Supporto > Modifiche', 'page_class' => self::class, 'related_path' => 'app/Console/Commands/NetgesconGitSyncCommand.php', 'related_commit' => '236aef5', 'is_visible' => true, 'is_highlighted' => false, 'recorded_at' => '2026-04-04 10:50', ], ]; } private function resolveSupportUpdatePageUrl(string $pageClass): ?string { if ($pageClass === '' || ! class_exists($pageClass) || ! method_exists($pageClass, 'getUrl')) { return null; } try { return $pageClass::getUrl(); } catch (Throwable) { return null; } } private function buildSupportUpdateReleaseKey(string $title): string { $base = Str::slug(Str::limit($title, 80, ''), '-'); $base = $base !== '' ? $base : 'support-update'; $key = $base; $i = 1; while (SupportUpdateEntry::query()->where('release_key', $key)->exists()) { $i++; $key = $base . '-' . $i; } return $key; } public function setActiveTab(string $tab): void { if (! in_array($tab, ['manuale', 'errori', 'commit', 'migliorie'], true)) { return; } $this->activeTab = $tab; } public function resetSupportUpdateForm(): void { $this->supportUpdateFormId = null; $this->supportUpdateFormCategory = 'implementazione'; $this->supportUpdateFormAudience = 'utenti'; $this->supportUpdateFormTitle = ''; $this->supportUpdateFormSummary = ''; $this->supportUpdateFormImpact = ''; $this->supportUpdateFormPageLabel = ''; $this->supportUpdateFormPageClass = ''; $this->supportUpdateFormRelatedPath = ''; $this->supportUpdateFormRelatedCommit = ''; $this->supportUpdateFormRecordedAt = now()->format('Y-m-d H:i'); $this->supportUpdateFormVisible = true; $this->supportUpdateFormHighlighted = false; } public function editSupportUpdateEntry(int $entryId): void { if (! $this->isSupportUpdateRegistryReady()) { return; } $entry = SupportUpdateEntry::query()->find($entryId); if (! $entry instanceof SupportUpdateEntry) { Notification::make()->title('Voce registro non trovata')->warning()->send(); return; } $this->supportUpdateFormId = (int) $entry->id; $this->supportUpdateFormCategory = (string) $entry->category; $this->supportUpdateFormAudience = (string) $entry->audience; $this->supportUpdateFormTitle = (string) $entry->title; $this->supportUpdateFormSummary = (string) $entry->summary; $this->supportUpdateFormImpact = (string) ($entry->impact ?? ''); $this->supportUpdateFormPageLabel = (string) ($entry->page_label ?? ''); $this->supportUpdateFormPageClass = (string) ($entry->page_class ?? ''); $this->supportUpdateFormRelatedPath = (string) ($entry->related_path ?? ''); $this->supportUpdateFormRelatedCommit = (string) ($entry->related_commit ?? ''); $this->supportUpdateFormRecordedAt = (string) ($entry->recorded_at ?: now()->format('Y-m-d H:i')); $this->supportUpdateFormVisible = (bool) $entry->is_visible; $this->supportUpdateFormHighlighted = (bool) $entry->is_highlighted; Notification::make()->title('Voce caricata nel form')->success()->send(); } public function saveSupportUpdateEntry(): void { if (! $this->isSupportUpdateRegistryReady()) { Notification::make()->title('Registro DB non pronto')->warning()->send(); return; } $title = trim($this->supportUpdateFormTitle); $summary = trim($this->supportUpdateFormSummary); $recordedAt = mb_substr(trim($this->supportUpdateFormRecordedAt), 0, 40); $relatedCommit = mb_substr(trim($this->supportUpdateFormRelatedCommit), 0, 40); if ($title === '' || $summary === '') { Notification::make()->title('Titolo e riepilogo sono obbligatori')->warning()->send(); return; } $category = in_array($this->supportUpdateFormCategory, ['implementazione', 'bugfix', 'debug'], true) ? $this->supportUpdateFormCategory : 'implementazione'; $audience = in_array($this->supportUpdateFormAudience, ['utenti', 'supporto'], true) ? $this->supportUpdateFormAudience : 'utenti'; $entry = $this->supportUpdateFormId !== null ? SupportUpdateEntry::query()->find($this->supportUpdateFormId) : new SupportUpdateEntry(); if (! $entry instanceof SupportUpdateEntry) { $entry = new SupportUpdateEntry(); } $releaseKey = $entry->exists ? (string) $entry->release_key : $this->buildSupportUpdateReleaseKey($title); $entry->fill([ 'release_key' => $releaseKey, 'category' => $category, 'audience' => $audience, 'title' => $title, 'summary' => $summary, 'impact' => trim($this->supportUpdateFormImpact), 'page_label' => trim($this->supportUpdateFormPageLabel), 'page_class' => trim($this->supportUpdateFormPageClass), 'related_path' => trim($this->supportUpdateFormRelatedPath), 'related_commit' => $relatedCommit, 'recorded_at' => $recordedAt !== '' ? $recordedAt : now()->format('Y-m-d H:i'), 'is_visible' => $this->supportUpdateFormVisible, 'is_highlighted' => $this->supportUpdateFormHighlighted, ]); $entry->save(); $this->loadSupportUpdateEntries(); $this->resetSupportUpdateForm(); Notification::make()->title('Voce registro salvata')->success()->send(); } public function toggleSupportUpdateVisibility(int $entryId): void { if (! $this->isSupportUpdateRegistryReady()) { return; } $entry = SupportUpdateEntry::query()->find($entryId); if (! $entry instanceof SupportUpdateEntry) { return; } $entry->is_visible = ! (bool) $entry->is_visible; $entry->save(); $this->loadSupportUpdateEntries(); } private function startGitSyncJob(): void { $jobId = now()->format('YmdHis') . '-' . Str::lower(Str::random(6)); $jobsDir = storage_path('app/support/git-sync-jobs'); if (! is_dir($jobsDir)) { @mkdir($jobsDir, 0775, true); } $progressPath = $jobsDir . '/' . $jobId . '.progress.json'; $logPath = $jobsDir . '/' . $jobId . '.log'; $this->gitSyncJobId = $jobId; $this->gitSyncInProgress = true; $this->gitSyncProgressPercent = 1; $this->gitSyncProgressMessage = 'Preparazione sync Git da Gitea...'; $this->gitSyncProgressStatus = 'running'; @file_put_contents($progressPath, json_encode([ 'timestamp' => now()->toIso8601String(), 'percent' => 1, 'message' => 'Preparazione sync Git da Gitea...', 'status' => 'running', 'exit_code' => null, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $args = [ 'php', 'artisan', 'netgescon:git-sync', '--remote=' . trim($this->gitRemote), '--branch=' . trim($this->gitBranch), '--progress-file=' . $progressPath, ]; if ($this->gitForce) { $args[] = '--force'; } $escaped = implode(' ', array_map(static fn(string $arg): string => escapeshellarg($arg), $args)); $shell = 'nohup ' . $escaped . ' > ' . escapeshellarg($logPath) . ' 2>&1 & echo $!'; $started = Process::path(base_path())->run(['bash', '-lc', $shell]); if (! $started->successful()) { $this->gitSyncInProgress = false; $this->gitSyncProgressStatus = 'failed'; $this->gitSyncProgressPercent = 100; $this->gitSyncProgressMessage = 'Impossibile avviare sync Git'; $this->lastGitSyncExitCode = 1; $this->lastGitSyncOutput = trim((string) ($started->output() !== '' ? $started->output() : $started->errorOutput())); $this->lastGitSyncIssue = 'Avvio sync Git fallito.'; return; } $this->gitSyncProgressPercent = 5; $this->gitSyncProgressMessage = 'Job sync Git avviato (PID ' . trim($started->output()) . ')'; $this->gitSyncProgressStatus = 'running'; Notification::make() ->title('Sync Git avviata') ->body('Il nodo verra allineato da Gitea senza usare il terminale.') ->success() ->send(); } private function startNodeRefreshJob(bool $skipMaintenance = false): void { $jobId = now()->format('YmdHis') . '-' . Str::lower(Str::random(6)); $jobsDir = storage_path('app/support/update-jobs'); if (! is_dir($jobsDir)) { @mkdir($jobsDir, 0775, true); } $progressPath = $jobsDir . '/' . $jobId . '.progress.json'; $logPath = $jobsDir . '/' . $jobId . '.log'; $this->updateJobId = $jobId; $this->updateInProgress = true; $this->updateProgressPercent = 1; $this->updateProgressMessage = $skipMaintenance ? 'Preparazione refresh nodo da Gitea senza maintenance mode...' : 'Preparazione refresh nodo da Gitea...'; $this->updateProgressStatus = 'running'; @file_put_contents($progressPath, json_encode([ 'timestamp' => now()->toIso8601String(), 'percent' => 1, 'message' => $skipMaintenance ? 'Preparazione refresh nodo da Gitea senza maintenance mode...' : 'Preparazione refresh nodo da Gitea...', 'status' => 'running', 'exit_code' => null, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $args = [ 'php', 'artisan', 'netgescon:node-refresh', '--remote=' . trim($this->gitRemote), '--branch=' . trim($this->gitBranch), '--progress-file=' . $progressPath, ]; $adminId = $this->resolveAmministratoreId(); $requireDrive = $this->shouldRequireDrivePreupdateBackup(); $driveEnabled = $this->shouldAttemptDrivePreupdateBackup(); if ($this->gitForce) { $args[] = '--force'; } if ($this->updateSkipBackup) { $args[] = '--skip-backup'; } if ($skipMaintenance) { $args[] = '--skip-maintenance'; } if ($driveEnabled && $adminId > 0) { $args[] = '--drive'; $args[] = '--admin-id=' . $adminId; } if ($requireDrive) { $args[] = '--require-drive'; } $escaped = implode(' ', array_map(static fn(string $arg): string => escapeshellarg($arg), $args)); $shell = 'nohup ' . $escaped . ' > ' . escapeshellarg($logPath) . ' 2>&1 & echo $!'; $started = Process::path(base_path())->run(['bash', '-lc', $shell]); if (! $started->successful()) { $this->updateInProgress = false; $this->updateProgressStatus = 'failed'; $this->updateProgressPercent = 100; $this->updateProgressMessage = 'Impossibile avviare refresh nodo'; $this->lastUpdateExitCode = 1; $this->lastUpdateOutput = trim((string) ($started->output() !== '' ? $started->output() : $started->errorOutput())); $this->lastUpdateIssue = 'Avvio refresh nodo fallito.'; return; } $this->updateProgressPercent = 5; $this->updateProgressMessage = 'Job refresh nodo avviato (PID ' . trim($started->output()) . ')'; $this->updateProgressStatus = 'running'; Notification::make() ->title('Refresh nodo avviato') ->body($skipMaintenance ? 'Refresh nodo avviato senza maintenance mode. Usalo solo come percorso di riserva su macchine dove il blocco accessi non si attiva.' : 'Sync da Gitea, migrate e rebuild cache verranno eseguiti direttamente sul nodo da browser.') ->success() ->send(); } private function loadManualSections(): void { $this->manualSections = []; $documents = [ [ 'key' => 'manuale-operativo', 'title' => 'Manuale operativo sviluppo', 'description' => 'Quadro principale: come stiamo sviluppando, cosa e stato implementato e come continuare senza ripartire da zero.', 'path' => 'docs/support/MANUALE-SVILUPPO-OPERATIVO.md', ], [ 'key' => 'aggiornamenti-staging-git', 'title' => 'Aggiornamenti staging via Git', 'description' => 'Flusso ufficiale Day0 -> Gitea -> staging, comando predisposto usato dalla UI e riepilogo dei fix strutturali che stiamo distribuendo.', 'path' => 'docs/support/AGGIORNAMENTI-STAGING-GIT.md', ], [ 'key' => 'pbx-panasonic', 'title' => 'PBX Panasonic stato operativo', 'description' => 'Riepilogo strutturato del lavoro fatto su CTI, CSTA, click-to-call, stato attuale e prossimi test sul centralino Panasonic.', 'path' => 'docs/support/PBX-PANASONIC-STATO-OPERATIVO.md', ], [ 'key' => 'bug-risolti', 'title' => 'Bug risolti e validazioni', 'description' => 'Registro sintetico dei problemi corretti, dello stato di verifica e dei punti ancora da testare.', 'path' => 'docs/support/BUG-RISOLTI-E-VALIDAZIONI.md', ], [ 'key' => 'continuita-agent', 'title' => 'Continuita sviluppo e agent', 'description' => 'Regole pratiche per aggiornare documentazione, note commit e materiali utili al riaggancio delle prossime sessioni.', 'path' => 'docs/support/CONTINUITA-SVILUPPO-AGENT.md', ], [ 'key' => 'coworking-moduli', 'title' => 'Architettura ruoli, moduli e coworking', 'description' => 'Direzione architetturale di NetGescon come spazio unico abilitato per ruoli multipli e moduli attivabili sulla stessa base dominio.', 'path' => 'docs/support/ARCHITETTURA-RUOLI-MODULI-COWORKING.md', ], [ 'key' => 'server-debug', 'title' => 'Distribuzione server e debug separato', 'description' => 'Primo piano pratico per portare NetGescon su nodi separati dalla rete locale e stabilizzare deploy, collaudo e diagnostica.', 'path' => 'docs/support/DISTRIBUZIONE-SERVER-DEBUG.md', ], ]; foreach ($documents as $document) { $relativePath = (string) $document['path']; $absolutePath = base_path($relativePath); $exists = is_file($absolutePath); $raw = $exists ? (string) @file_get_contents($absolutePath) : '# Documento non trovato' . PHP_EOL . PHP_EOL . '- Path atteso: `' . $relativePath . '`'; $this->manualSections[] = [ 'key' => (string) $document['key'], 'title' => (string) $document['title'], 'description' => (string) $document['description'], 'path' => $relativePath, 'exists' => $exists, 'html' => $this->renderSupportMarkdown($raw), ]; } } public function renderSupportMarkdown(string $markdown): string { $replacements = [ '[E!]' => 'E!', '[U]' => 'U', '[P]' => 'P', '[E]' => 'E', ]; return str_replace( array_keys($replacements), array_values($replacements), Str::markdown($markdown) ); } private function runGitProcess(array $arguments): ?string { return $this->runGitProcessAt($this->resolveGitWorkspacePath(), $arguments); } private function detectGitSyncIssue(string $output): string { $haystack = Str::lower($output); if (str_contains($haystack, 'working tree sporco')) { return 'Il nodo ha modifiche locali non committate: la sync Git e stata bloccata per sicurezza.'; } if (str_contains($haystack, 'not possible to fast-forward') || str_contains($haystack, 'divergent branches')) { return 'Il branch locale non e in fast-forward rispetto a Gitea. Serve riallineamento controllato.'; } if (str_contains($haystack, 'could not read from remote repository') || str_contains($haystack, 'permission denied')) { return 'Il nodo non riesce a leggere il repository remoto Gitea. Verificare chiavi SSH o permessi Git.'; } return 'Sync Git terminata con errore tecnico. Controlla l output completo.'; } public function setBugFilterStatus(string $status): void { if (! in_array($status, ['all', 'open', 'resolved'], true)) { return; } $this->bugFilterStatus = $status; $this->loadRuntimeErrors(); } public function markBugResolved(string $fingerprint): void { if (! isset($this->bugRegistry[$fingerprint])) { return; } $this->bugRegistry[$fingerprint]['status'] = 'resolved'; $this->bugRegistry[$fingerprint]['resolved_at'] = now()->toIso8601String(); if (trim($this->bugResolutionNote) !== '' && $this->selectedBugFingerprint === $fingerprint) { $this->bugRegistry[$fingerprint]['resolution_note'] = mb_substr(trim($this->bugResolutionNote), 0, 2000); } $this->persistBugRegistry(); $this->loadRuntimeErrors(); Notification::make()->title('BUG segnato come risolto')->success()->send(); } public function reopenBug(string $fingerprint): void { if (! isset($this->bugRegistry[$fingerprint])) { return; } $this->bugRegistry[$fingerprint]['status'] = 'open'; $this->bugRegistry[$fingerprint]['resolved_at'] = null; $this->persistBugRegistry(); $this->loadRuntimeErrors(); Notification::make()->title('BUG riaperto')->warning()->send(); } public function selectBugForResolution(string $fingerprint): void { if (! isset($this->bugRegistry[$fingerprint])) { return; } $this->selectedBugFingerprint = $fingerprint; $this->bugResolutionNote = (string) ($this->bugRegistry[$fingerprint]['resolution_note'] ?? ''); } public function saveBugResolutionNote(): void { $fingerprint = trim((string) ($this->selectedBugFingerprint ?? '')); if ($fingerprint === '' || ! isset($this->bugRegistry[$fingerprint])) { Notification::make()->title('Seleziona un BUG')->warning()->send(); return; } $this->bugRegistry[$fingerprint]['resolution_note'] = mb_substr(trim($this->bugResolutionNote), 0, 2000); $this->persistBugRegistry(); $this->loadRuntimeErrors(); Notification::make()->title('Nota risoluzione salvata')->success()->send(); } public function createManualBug(): void { $title = trim($this->manualBugTitle); $details = trim($this->manualBugDetails); if ($title === '') { Notification::make()->title('Titolo BUG mancante')->warning()->send(); return; } $context = $details !== '' ? $details : 'Nessun dettaglio aggiuntivo.'; $this->appendSupportEvent('manual_bug', $title, $context); $this->manualBugTitle = ''; $this->manualBugDetails = ''; $this->loadRuntimeErrors(); Notification::make()->title('BUG manuale creato')->success()->send(); } public function onSelectedCommitHashUpdated(): void { $hash = trim((string) ($this->selectedCommitHash ?? '')); $this->newCommitNote = $hash !== '' ? (string) ($this->commitNotes[$hash] ?? '') : ''; } public function saveCommitNote(): void { $hash = trim((string) ($this->selectedCommitHash ?? '')); $note = trim($this->newCommitNote); if ($hash === '') { Notification::make() ->title('Seleziona un commit') ->warning() ->send(); return; } if ($note === '') { unset($this->commitNotes[$hash]); } else { $this->commitNotes[$hash] = mb_substr($note, 0, 2000); } $this->persistCommitNotes(); Notification::make() ->title('Nota commit salvata') ->success() ->send(); } public function getCommitNote(string $hash): string { return (string) ($this->commitNotes[$hash] ?? ''); } private function detectUpdateIssue(string $output, int $exitCode): ?string { if ($exitCode === 0) { return null; } $haystack = Str::lower($output); if (str_contains($haystack, 'manifest endpoint non disponibile sul server update') || str_contains($haystack, 'manifest endpoint non trovato') || str_contains($haystack, '/api/v1/distribution/updates/manifest')) { return 'Il server distribution non espone ancora il manifest update richiesto. Per staging il flusso corretto e usare "Aggiorna staging da Gitea" nel tab Manuale; il canale distribution resta separato e non e ancora il percorso operativo principale.'; } if (str_contains($haystack, 'curl error 28') || str_contains($haystack, 'operation timed out') || str_contains($haystack, 'timeout')) { return 'Timeout di rete durante update (cURL error 28). Verifica connettivita server/repository e riprova.'; } if (str_contains($haystack, 'could not resolve host') || str_contains($haystack, 'temporary failure in name resolution')) { return 'Errore DNS o host remoto non raggiungibile durante update.'; } if (str_contains($haystack, 'permission denied')) { return 'Permessi insufficienti sul filesystem o su git durante update.'; } return 'Aggiornamento terminato con errore tecnico. Controlla l\'output completo.'; } private function appendSupportEvent(string $type, string $message, string $context): void { $record = [ 'timestamp' => now()->toIso8601String(), 'type' => $type, 'message' => $message, 'context' => mb_substr(trim($context), 0, 2000), ]; $json = json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (! is_string($json) || $json === '') { return; } @file_put_contents(storage_path('logs/support-events.ndjson'), $json . PHP_EOL, FILE_APPEND); } private function loadRuntimeErrors(): void { $events = []; $this->appendFromNdjson(storage_path('logs/runtime-errors.ndjson'), 'runtime-errors.ndjson', $events); $this->appendFromNdjson(storage_path('logs/support-events.ndjson'), 'support-events.ndjson', $events); $this->appendFromNdjson(storage_path('app/support/online-runtime-errors.ndjson'), 'online-runtime-errors.ndjson', $events); $this->appendFromNdjson(base_path('docs/support/online-runtime-errors.ndjson'), 'online-runtime-errors.git', $events); $this->appendFromLaravelLog(storage_path('logs/laravel.log'), $events); usort($events, static function (array $a, array $b): int { return strcmp((string) ($b['timestamp'] ?? ''), (string) ($a['timestamp'] ?? '')); }); $this->loadBugRegistry(); $this->runtimeErrors = $this->collapseEventsToBugRows($events); $this->persistBugRegistry(); $this->openBugCount = 0; $this->resolvedBugCount = 0; foreach ($this->bugRegistry as $meta) { if (($meta['status'] ?? 'open') === 'resolved') { $this->resolvedBugCount++; } else { $this->openBugCount++; } } } private function appendFromNdjson(string $file, string $sourceLabel, array &$events): void { if (! is_file($file)) { return; } $lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); if (! is_array($lines) || $lines === []) { return; } foreach (array_reverse($lines) as $line) { $decoded = json_decode((string) $line, true); if (! is_array($decoded)) { continue; } $events[] = [ 'timestamp' => (string) ($decoded['timestamp'] ?? '-'), 'type' => (string) ($decoded['type'] ?? 'runtime'), 'message' => (string) ($decoded['message'] ?? 'Errore runtime'), 'context' => (string) ($decoded['context'] ?? ''), 'source' => $sourceLabel, ]; if (count($events) >= 250) { break; } } } private function appendFromLaravelLog(string $file, array &$events): void { if (! is_file($file)) { return; } $lines = @file($file, FILE_IGNORE_NEW_LINES); if (! is_array($lines) || $lines === []) { return; } $slice = array_slice($lines, -4000); foreach ($slice as $line) { if (! is_string($line) || trim($line) === '') { continue; } if (preg_match('/^\[(?[^\]]+)\]\s+[^.]+\.(?[A-Z]+):\s+(?.+)$/', $line, $m) !== 1) { continue; } $level = strtolower((string) ($m['level'] ?? '')); if (! in_array($level, ['error', 'critical', 'emergency', 'alert'], true)) { continue; } $events[] = [ 'timestamp' => (string) ($m['ts'] ?? '-'), 'type' => 'laravel_' . $level, 'message' => Str::limit(trim((string) ($m['msg'] ?? '')), 300), 'context' => '', 'source' => 'laravel.log', ]; if (count($events) >= 450) { break; } } } /** * @param array $events * @return array */ private function collapseEventsToBugRows(array $events): array { $rows = []; foreach ($events as $event) { $fingerprint = sha1(implode('|', [ (string) ($event['source'] ?? ''), (string) ($event['type'] ?? ''), (string) ($event['message'] ?? ''), (string) ($event['context'] ?? ''), ])); if (! isset($this->bugRegistry[$fingerprint])) { $this->bugRegistry[$fingerprint] = [ 'id' => $this->nextBugId, 'status' => 'open', 'first_seen' => (string) ($event['timestamp'] ?? '-'), 'last_seen' => (string) ($event['timestamp'] ?? '-'), 'resolved_at' => null, 'resolution_note' => '', 'title' => Str::limit((string) ($event['message'] ?? 'Errore runtime'), 160), ]; $this->nextBugId++; } $this->bugRegistry[$fingerprint]['last_seen'] = (string) ($event['timestamp'] ?? '-'); $bugStatus = (string) ($this->bugRegistry[$fingerprint]['status'] ?? 'open'); if (! isset($rows[$fingerprint])) { $rows[$fingerprint] = [ 'timestamp' => (string) ($event['timestamp'] ?? '-'), 'type' => (string) ($event['type'] ?? 'runtime'), 'message' => (string) ($event['message'] ?? 'Errore runtime'), 'context' => (string) ($event['context'] ?? ''), 'source' => (string) ($event['source'] ?? '-'), 'fingerprint' => $fingerprint, 'bug_id' => (int) $this->bugRegistry[$fingerprint]['id'], 'bug_code' => sprintf('BUG-%04d', (int) $this->bugRegistry[$fingerprint]['id']), 'bug_status' => $bugStatus, 'occurrences' => 0, 'resolution_note' => (string) ($this->bugRegistry[$fingerprint]['resolution_note'] ?? ''), 'resolved_at' => isset($this->bugRegistry[$fingerprint]['resolved_at']) && is_string($this->bugRegistry[$fingerprint]['resolved_at']) ? $this->bugRegistry[$fingerprint]['resolved_at'] : null, ]; } $rows[$fingerprint]['occurrences']++; $rows[$fingerprint]['bug_status'] = $bugStatus; $rows[$fingerprint]['resolution_note'] = (string) ($this->bugRegistry[$fingerprint]['resolution_note'] ?? ''); $rows[$fingerprint]['resolved_at'] = isset($this->bugRegistry[$fingerprint]['resolved_at']) && is_string($this->bugRegistry[$fingerprint]['resolved_at']) ? $this->bugRegistry[$fingerprint]['resolved_at'] : null; } $final = array_values($rows); usort($final, static function (array $a, array $b): int { return strcmp((string) ($b['timestamp'] ?? ''), (string) ($a['timestamp'] ?? '')); }); if ($this->bugFilterStatus !== 'all') { $final = array_values(array_filter($final, fn(array $row): bool => ($row['bug_status'] ?? 'open') === $this->bugFilterStatus)); } return array_slice($final, 0, 120); } private function loadBugRegistry(): void { $this->bugRegistry = []; $this->nextBugId = 1; if ($this->isSupportBugRegistryReady()) { $rows = SupportBugRegistry::query() ->orderBy('bug_id') ->get(); foreach ($rows as $row) { $fingerprint = (string) $row->fingerprint; if ($fingerprint === '') { continue; } $id = (int) $row->bug_id; if ($id <= 0) { continue; } $this->bugRegistry[$fingerprint] = [ 'id' => $id, 'status' => (string) ($row->status ?: 'open'), 'first_seen' => (string) ($row->first_seen ?: '-'), 'last_seen' => (string) ($row->last_seen ?: '-'), 'resolved_at' => $row->resolved_at !== null ? (string) $row->resolved_at : null, 'resolution_note' => (string) ($row->resolution_note ?? ''), 'title' => (string) ($row->title ?? ''), ]; $this->nextBugId = max($this->nextBugId, $id + 1); } if ($this->bugRegistry !== []) { return; } } $path = storage_path('app/support/bug-tracker.json'); if (! is_file($path)) { return; } $decoded = json_decode((string) @file_get_contents($path), true); if (! is_array($decoded)) { return; } $next = isset($decoded['next_bug_id']) && is_numeric($decoded['next_bug_id']) ? (int) $decoded['next_bug_id'] : 1; $this->nextBugId = max(1, $next); if (! is_array($decoded['bugs'] ?? null)) { return; } foreach ($decoded['bugs'] as $fingerprint => $meta) { if (! is_string($fingerprint) || ! is_array($meta)) { continue; } $id = isset($meta['id']) && is_numeric($meta['id']) ? (int) $meta['id'] : null; if (! $id) { continue; } $this->bugRegistry[$fingerprint] = [ 'id' => $id, 'status' => (string) ($meta['status'] ?? 'open'), 'first_seen' => (string) ($meta['first_seen'] ?? '-'), 'last_seen' => (string) ($meta['last_seen'] ?? '-'), 'resolved_at' => isset($meta['resolved_at']) && is_string($meta['resolved_at']) ? $meta['resolved_at'] : null, 'resolution_note' => (string) ($meta['resolution_note'] ?? ''), 'title' => (string) ($meta['title'] ?? ''), ]; } } private function persistBugRegistry(): void { if ($this->isSupportBugRegistryReady()) { $rows = []; foreach ($this->bugRegistry as $fingerprint => $meta) { if (! is_string($fingerprint) || ! is_array($meta)) { continue; } $rows[] = [ 'fingerprint' => $fingerprint, 'bug_id' => (int) ($meta['id'] ?? 0), 'status' => (string) ($meta['status'] ?? 'open'), 'title' => (string) ($meta['title'] ?? ''), 'first_seen' => (string) ($meta['first_seen'] ?? '-'), 'last_seen' => (string) ($meta['last_seen'] ?? '-'), 'resolved_at' => isset($meta['resolved_at']) && is_string($meta['resolved_at']) ? $meta['resolved_at'] : null, 'resolution_note' => (string) ($meta['resolution_note'] ?? ''), 'created_at' => now(), 'updated_at' => now(), ]; } if ($rows !== []) { SupportBugRegistry::query()->upsert( $rows, ['fingerprint'], ['bug_id', 'status', 'title', 'first_seen', 'last_seen', 'resolved_at', 'resolution_note', 'updated_at'] ); } } $path = storage_path('app/support/bug-tracker.json'); $dir = dirname($path); if (! is_dir($dir)) { @mkdir($dir, 0775, true); } $payload = [ 'next_bug_id' => $this->nextBugId, 'bugs' => $this->bugRegistry, ]; @file_put_contents( $path, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ); } /** @return array|null */ public function getHighlightedBugProperty(): ?array { if ($this->selectedBugFingerprint !== null) { foreach ($this->runtimeErrors as $row) { if (($row['fingerprint'] ?? null) === $this->selectedBugFingerprint) { return $row; } } } foreach ($this->runtimeErrors as $row) { if (($row['bug_code'] ?? '') === 'BUG-0033') { return $row; } } return $this->runtimeErrors[0] ?? null; } public function getHighlightedBugHintProperty(): ?string { $row = $this->highlightedBug; if (! is_array($row)) { return null; } $message = Str::lower((string) ($row['message'] ?? '')); if (str_contains($message, 'filament\\notifications\\collection') || str_contains($message, 'argument #1 ($notification) must be of type array, int given')) { return 'Causa probabile: payload notifiche Filament corrotto in sessione. Il bootstrap applicativo ora filtra i valori non-array prima che Livewire li rilegga.'; } return null; } private function loadCommitNotes(): void { $this->commitNotes = []; $path = storage_path('app/support/commit-notes.json'); if (! is_file($path)) { return; } $decoded = json_decode((string) @file_get_contents($path), true); if (! is_array($decoded)) { return; } foreach ($decoded as $hash => $note) { if (! is_string($hash) || ! is_string($note)) { continue; } $this->commitNotes[$hash] = $note; } } private function persistCommitNotes(): void { $path = storage_path('app/support/commit-notes.json'); $dir = dirname($path); if (! is_dir($dir)) { @mkdir($dir, 0775, true); } @file_put_contents( $path, json_encode($this->commitNotes, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ); } }