2238 lines
86 KiB
PHP
2238 lines
86 KiB
PHP
<?php
|
|
namespace App\Filament\Pages\Supporto;
|
|
|
|
use App\Models\Amministratore;
|
|
use App\Models\Stabile;
|
|
use App\Models\SupportBugRegistry;
|
|
use App\Models\SupportUpdateEntry;
|
|
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\Facades\Schema;
|
|
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{hash:string,short_hash:string,author:string,date:string,message:string}> */
|
|
public array $pendingGitCommits = [];
|
|
|
|
public int $pendingGitCommitCount = 0;
|
|
|
|
/** @var array<int,array{label:string,path:string,area:string}> */
|
|
public array $pendingGitChangedAreas = [];
|
|
|
|
/** @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{id:int,release_key:string,category:string,audience:string,title:string,summary:string,impact:string,page_label:string,page_class:string,related_path:string,related_commit:string,recorded_at:string,is_highlighted:bool,page_url:?string}> */
|
|
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<string,mixed>|null */
|
|
public ?array $lastNotificationSanitization = null;
|
|
|
|
/** @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 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();
|
|
|
|
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 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 loadPendingGitPreview(): void
|
|
{
|
|
$this->pendingGitCommits = [];
|
|
$this->pendingGitCommitCount = 0;
|
|
$this->pendingGitChangedAreas = [];
|
|
$this->pendingGitPreviewIssue = null;
|
|
|
|
$remote = trim($this->gitRemote);
|
|
$branch = trim($this->gitBranch);
|
|
|
|
if ($remote === '' || $branch === '') {
|
|
return;
|
|
}
|
|
|
|
$fetch = Process::path(base_path())
|
|
->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.';
|
|
}
|
|
|
|
$range = 'HEAD..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(base_path())->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<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 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 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);
|
|
}
|
|
|
|
private function resolveChangedAreaLabel(string $path): string
|
|
{
|
|
if (str_starts_with($path, 'app/Filament/Pages/')) {
|
|
return 'pagina Filament';
|
|
}
|
|
|
|
if (str_starts_with($path, 'resources/views/filament/pages/')) {
|
|
return 'view pagina';
|
|
}
|
|
|
|
if (str_starts_with($path, 'app/Console/Commands/')) {
|
|
return 'comando applicativo';
|
|
}
|
|
|
|
if (str_starts_with($path, 'docs/support/')) {
|
|
return 'documentazione supporto';
|
|
}
|
|
|
|
return 'area applicativa';
|
|
}
|
|
|
|
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<int,array<string,mixed>> */
|
|
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 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!]' => '<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;
|
|
|
|
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<string,mixed>|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)
|
|
);
|
|
}
|
|
}
|