netgescon-day0/app/Filament/Pages/Supporto/Modifiche.php

1613 lines
60 KiB
PHP

<?php
namespace App\Filament\Pages\Supporto;
use App\Models\Amministratore;
use App\Models\Stabile;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use Throwable;
use UnitEnum;
class Modifiche extends Page
{
protected static ?string $navigationLabel = 'Modifiche';
protected static ?string $title = 'Modifiche e Changelog';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-document-text';
protected static UnitEnum|string|null $navigationGroup = 'Supporto';
protected static ?int $navigationSort = 30;
protected static ?string $slug = 'supporto/modifiche';
protected string $view = 'filament.pages.supporto.modifiche';
public string $currentVersion = '0.0.0';
public string $versionSource = 'config';
public string $updateChannel = 'free';
public bool $updateForce = false;
public bool $updateDryRun = false;
public bool $updateSkipBackup = false;
public ?int $lastUpdateExitCode = null;
public ?string $lastUpdateOutput = null;
public ?string $lastUpdateAt = null;
public ?string $lastUpdateIssue = null;
public ?string $lastConnectivityCheckOutput = null;
/** @var array<string,mixed>|null */
public ?array $lastManifestPreview = null;
/** @var array<string,mixed>|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 $gitCurrentBranch = null;
public ?string $gitCurrentCommit = null;
public ?string $gitRemoteCommit = null;
public bool $gitWorkingTreeDirty = false;
public string $gitAheadBehind = '-';
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<string,array{id:int,status:string,first_seen:string,last_seen:string,resolved_at:?string,resolution_note:string,title:string}> */
public array $bugRegistry = [];
public int $nextBugId = 1;
/** @var array<int,array{timestamp:string,type:string,message:string,context:string,source:string,fingerprint:string,bug_id:int,bug_code:string,bug_status:string,occurrences:int,resolution_note:string,resolved_at:?string}> */
public array $runtimeErrors = [];
/** @var array<string,string> */
public array $commitNotes = [];
/** @var array<int,array{hash:string,short_hash:string,author:string,date:string,message:string}> */
public array $latestCommits = [];
/** @var array<int,array{page:string,path:string,commit:string,message:string}> */
public array $recentFilamentPages = [];
/** @var array<int,string> */
public array $functionalHighlights = [];
/** @var array<int,array{key:string,title:string,description:string,path:string,exists:bool,html:string}> */
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 $user && method_exists($user, 'hasAnyRole')
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore']);
}
public function reloadDashboardData(): void
{
$this->loadVersionInfo();
$this->loadLatestCommits();
$this->loadCommitNotes();
$this->loadRuntimeErrors();
$this->loadRecentFilamentPages();
$this->loadFunctionalHighlights();
$this->loadManualSections();
$this->loadDevelopmentSnapshot();
$this->loadGitWorkspaceStatus();
$this->loadLatestBackupSummary();
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 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();
}
/**
* @return array<int,string>
*/
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 = [];
$format = '%H|%h|%an|%ad|%s';
$command = 'git log -n 20 --date=format:"%d/%m/%Y %H:%M" --pretty=format:"' . $format . '"';
$result = Process::path(base_path())->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 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<string,mixed> */
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 = [];
$command = 'git log -n 30 --name-only --pretty=format:"@@@%h|%s" -- app/Filament/Pages resources/views/filament/pages';
$result = Process::path(base_path())->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
{
$this->gitCurrentBranch = $this->runGitProcess(['rev-parse', '--abbrev-ref', 'HEAD']);
$this->gitCurrentCommit = $this->runGitProcess(['rev-parse', '--short', 'HEAD']);
$this->gitRemoteCommit = $this->runGitProcess(['rev-parse', '--short', 'refs/remotes/' . $this->gitRemote . '/' . $this->gitBranch]);
$this->gitWorkingTreeDirty = trim((string) ($this->runGitProcess(['status', '--porcelain']) ?? '')) !== '';
$aheadBehind = $this->runGitProcess(['rev-list', '--left-right', '--count', 'HEAD...refs/remotes/' . $this->gitRemote . '/' . $this->gitBranch]);
if (is_string($aheadBehind) && preg_match('/^(\d+)\s+(\d+)$/', trim($aheadBehind), $matches) === 1) {
$this->gitAheadBehind = 'ahead ' . $matches[1] . ' / behind ' . $matches[2];
} else {
$this->gitAheadBehind = '-';
}
$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)) {
$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;
}
}
}
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('/(?<!^)([A-Z])/', ' $1', $name) ?? $name;
return trim($name);
}
public function setActiveTab(string $tab): void
{
if (! in_array($tab, ['manuale', 'errori', 'commit', 'migliorie'], true)) {
return;
}
$this->activeTab = $tab;
}
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 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',
],
];
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!]' => '<span class="inline-flex items-center gap-2"><span class="inline-block h-3 w-3 rounded-sm bg-danger-500"></span><span class="font-mono">E!</span></span>',
'[U]' => '<span class="inline-flex items-center gap-2"><span class="inline-block h-3 w-3 rounded-sm bg-success-500"></span><span class="font-mono">U</span></span>',
'[P]' => '<span class="inline-flex items-center gap-2"><span class="inline-block h-3 w-3 rounded-sm bg-primary-500"></span><span class="font-mono">P</span></span>',
'[E]' => '<span class="inline-flex items-center gap-2"><span class="inline-block h-3 w-3 rounded-sm bg-warning-500"></span><span class="font-mono">E</span></span>',
];
return str_replace(
array_keys($replacements),
array_values($replacements),
Str::markdown($markdown)
);
}
private function runGitProcess(array $arguments): ?string
{
$result = Process::path(base_path())
->timeout(120)
->run(array_merge(['git'], $arguments));
if (! $result->successful()) {
return null;
}
$output = trim((string) $result->output());
return $output !== '' ? $output : null;
}
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('/^\[(?<ts>[^\]]+)\]\s+[^.]+\.(?<level>[A-Z]+):\s+(?<msg>.+)$/', $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<int,array{timestamp:string,type:string,message:string,context:string,source:string}> $events
* @return array<int,array{timestamp:string,type:string,message:string,context:string,source:string,fingerprint:string,bug_id:int,bug_code:string,bug_status:string,occurrences:int,resolution_note:string,resolved_at:?string}>
*/
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;
$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
{
$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)
);
}
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)
);
}
}