operationId = app(BackupOperationState::class)->corrente(); $this->logSpediti = 0; $this->aggiorna(); } public function aggiorna(): void { if (! $this->operationId) { return; } $stato = app(BackupOperationState::class)->get($this->operationId); if (! $stato) { $this->operationId = null; $this->operazione = []; $this->logSpediti = 0; return; } $this->operazione = $stato; $this->spedisciAllaConsole(); // Il download parte da solo appena l'archivio e' pronto: per il backup // e' una comodita', per l'azzeramento e' il passaggio che sblocca la // cancellazione, quindi non puo' dipendere da un clic. if ($this->scaricaOra()) { $this->dispatch('scarica-backup', url: route('backup.file', $this->operationId)); } } /** * Tipi di operazione che questo componente racconta alla console. * * Serve a stabilire un proprietario per ogni operazione. Backup e zona * pericolosa seguono lo stesso stato condiviso, quindi senza questa * distinzione entrambi spedirebbero le stesse righe e la console le * mostrerebbe due volte — e a raddoppiare sarebbe anche il polling. * * @return array */ abstract protected function tipiSeguiti(): array; protected function seguoQuestaOperazione(): bool { return in_array((string) ($this->operazione['tipo'] ?? ''), $this->tipiSeguiti(), true); } /** * Manda alla console le righe nuove e lo stato di avanzamento. * * La console e' un componente Alpine fuori dal ciclo di Livewire (i suoi * contenuti non possono essere ri-renderizzati dal server senza cancellare * le righe scritte dal browser), quindi la comunicazione e' a eventi. */ protected function spedisciAllaConsole(): void { // Chi non e' il proprietario tace: si aggiorna solo per sapere che il // posto e' occupato e disabilitare i propri pulsanti. if (! $this->seguoQuestaOperazione()) { return; } $logs = $this->operazione['logs'] ?? []; // Coda accorciata: e' un'operazione nuova, si riparte da capo. if (count($logs) < $this->logSpediti) { $this->logSpediti = 0; } $nuove = array_slice($logs, $this->logSpediti); $this->logSpediti = count($logs); $this->dispatch( 'console-operazione', sorgente: $this->sorgenteConsole(), righe: array_values($nuove), etichetta: $this->etichettaOperazione(), fase: (string) ($this->operazione['fase'] ?? ''), percento: $this->percentuale, stato: (string) ($this->operazione['status'] ?? ''), ); } protected function sorgenteConsole(): string { return match ($this->operazione['tipo'] ?? null) { BackupOperationState::TIPO_BACKUP => 'backup', BackupOperationState::TIPO_RESTORE => 'ripristino', BackupOperationState::TIPO_RESET => 'azzeramento', default => 'server', }; } protected function etichettaOperazione(): string { return match ($this->operazione['tipo'] ?? null) { BackupOperationState::TIPO_BACKUP => 'Backup', BackupOperationState::TIPO_RESTORE => 'Ripristino', BackupOperationState::TIPO_RESET => 'Azzeramento', default => '', }; } /** * Vero se non c'e' gia' un'operazione in corso. * * Backup, ripristino e azzeramento lavorano tutti sullo stesso database e * sullo stesso disco: farne partire due insieme — due schede, due * amministratori — non li rallenta soltanto, li fa sovrascrivere a vicenda. */ protected function postoLibero(): bool { $inCorso = app(BackupOperationState::class)->corrente(); if ($inCorso === null) { $this->erroreOperazione = ''; return true; } $this->rifiutaPerOccupato($inCorso); return false; } /** * Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha * premuto il pulsante vuole comunque vedere a che punto e'. Il messaggio * dice quale operazione occupa il posto, perche' puo' essere un import * avviato da un'altra zona della pagina, o da un altro amministratore. */ private function rifiutaPerOccupato(string $inCorso): void { $this->operationId = $inCorso; $this->logSpediti = 0; $this->aggiorna(); $this->erroreOperazione = app(BackupOperationState::class)->messaggioOccupato($inCorso); } /** * Prende il posto e crea l'operazione; null se il posto e' occupato. * * postoLibero() resta il controllo rapido prima di validare il resto, ma fra * quel controllo e questo passo un'altra richiesta puo' arrivare prima — una * seconda scheda, un import partito da un'altra zona. E' qui, sotto il mutex * di BackupOperationState::occupa(), che si decide davvero. */ protected function nuovaOperazione(string $tipo): ?string { $operationId = (string) Str::uuid(); $stato = app(BackupOperationState::class); $inCorso = $stato->occupa($operationId, $tipo, 'In coda'); if ($inCorso !== null) { $this->rifiutaPerOccupato($inCorso); return null; } $stato->log($operationId, 'Operazione creata. In attesa del worker di coda.'); $this->logSpediti = 0; return $operationId; } public function chiudi(): void { $this->operationId = null; $this->operazione = []; $this->logSpediti = 0; } /** * True una volta sola per operazione: l'evento di download va emesso quando * l'archivio compare, non a ogni poll successivo. * * Il segno che l'evento e' gia' partito sta nello stato condiviso, ed e' * proprio per questo che solo il proprietario puo' arrivare fin qui: il * componente che segue l'operazione di riflesso lo consumerebbe per primo — * polla anche lui — spegnendo il download prima che il proprietario lo * emetta. L'archivio sarebbe pronto e non arriverebbe a nessuno, e per * l'azzeramento sarebbe pure il passo che sblocca la cancellazione. */ private function scaricaOra(): bool { if (! $this->seguoQuestaOperazione()) { return false; } $pronto = ($this->operazione['result']['file'] ?? null) !== null; $giaInviato = $this->operazione['result']['download_inviato'] ?? false; if (! $pronto || $giaInviato) { return false; } app(BackupOperationState::class)->update($this->operationId, function (array $state) { $state['result']['download_inviato'] = true; return $state; }); $this->operazione['result']['download_inviato'] = true; return true; } /** Percentuale della fase corrente, 0-100. */ public function getPercentualeProperty(): int { if (($this->operazione['status'] ?? null) === 'completed') { return 100; } $total = (int) ($this->operazione['total'] ?? 0); if ($total <= 0) { return 0; } return (int) min(100, round((int) ($this->operazione['processed'] ?? 0) / $total * 100)); } /** Se true c'e' un'operazione aperta: i pulsanti di avvio restano disabilitati. */ public function getInCorsoProperty(): bool { return $this->operationId !== null && in_array($this->operazione['status'] ?? '', ['queued', 'running'], true); } /** * Ogni quanto reinterrogare lo stato. * * Tre regimi, per tre esigenze diverse: * * - 750ms per il proprietario mentre un job lavora: la barra deve muoversi e * le righe di log devono arrivare mentre succedono, non dopo. * - 5s in attesa-download, dove si aspetta una persona che puo' metterci * minuti: bussare ogni 750ms vorrebbe dire migliaia di richieste inutili se * qualcuno lascia la scheda aperta e se ne va. * - 3s per il componente che NON possiede l'operazione. Non ha righe da * raccontare, ma deve accorgersi che il posto si e' liberato: senza, * il suo pulsante resterebbe disabilitato fino al ricaricamento della * pagina, con l'aria di essersi rotto. */ public function getIntervalloPollProperty(): string { if (! $this->seguoQuestaOperazione()) { return '3s'; } return $this->attesaDownload ? '5s' : '750ms'; } /** Vero quando l'azzeramento ha l'archivio pronto e aspetta la conferma. */ public function getAttesaDownloadProperty(): bool { return ($this->operazione['fase'] ?? null) === BackupOperationState::FASE_ATTESA_DOWNLOAD; } }