Files
termanager2/resources/views/livewire/settings/backup-manager.blade.php
2026-07-30 17:05:39 +02:00

393 lines
18 KiB
PHP

{{--
Zone 1 e 2 della pagina: creazione del pacchetto e ripristino.
Avanzamento e log non stanno piu' qui: vanno alla console unica agganciata in
fondo alla finestra (partials/console-operazioni.blade.php). In questa
sezione restano i comandi e l'esito, cioe' le uniche cose che riguardano
*questa* zona; prima invece la pagina aveva un pannello di avanzamento in
cima, una console a meta' e un "Dettaglio" richiudibile, e la stessa
operazione si raccontava in tre posti diversi.
Il JavaScript del caricamento sta ancora dentro l'attributo x-data, quindi
valgono le regole di sempre: dentro quel blocco NON possono comparire
virgolette doppie, da nessuna parte, neanche nei commenti — un attributo HTML
finisce alla prima, e questa pagina si e' gia' rotta cosi' due volte. Gli
apostrofi italiani stanno nei backtick, dove non richiedono accorgimenti: la
sequenza \' scritta a mano nel sorgente Blade chiude la stringa invece di
proteggere l'apostrofo.
--}}
@php
$op = $operazione;
$stato = $op['status'] ?? null;
$tipo = $op['tipo'] ?? null;
// "Il posto e' occupato da qualcun altro": i pulsanti si spengono, ma il
// messaggio deve dire quale operazione sta lavorando, non un generico
// divieto.
$occupato = $this->inCorso;
@endphp
<div class="space-y-6"
x-data="{
maxByte: {{ \App\Http\Controllers\Settings\BackupController::maxUploadKb() }} * 1000,
zipSelezionato: '',
uploadInCorso: false,
uploadPercento: 0,
erroreUpload: '',
ultimoAvanzamento: 0,
battito: null,
{{-- Ogni riga passa dalla console unica: qui non si tiene piu' un diario
locale, cosi' l'ordine dei fatti resta uno solo. --}}
log(riga, livello) {
this.$dispatch('console-riga', { testo: riga, livello: livello || 'info', sorgente: 'ripristino' });
},
/*
* Un caricamento fermo non genera eventi: senza qualcosa che batta il
* tempo, l'ultima riga resterebbe quella di dieci minuti prima e non si
* distinguerebbe lento da morto.
*/
avviaBattito() {
this.fermaBattito();
this.ultimoAvanzamento = Date.now();
this.battito = setInterval(() => {
const fermoDa = Math.round((Date.now() - this.ultimoAvanzamento) / 1000);
if (fermoDa >= 15) {
this.log(`...nessun avanzamento da ${fermoDa}s (${this.uploadPercento}%)`, 'attesa');
}
}, 15000);
},
fermaBattito() {
if (this.battito) {
clearInterval(this.battito);
this.battito = null;
}
},
/*
* Superato il tetto, e' nginx a chiudere la connessione a meta'
* trasferimento: la barra si pianta a una percentuale qualsiasi e non
* arriva nessun evento utile, quindi la pagina resta muta con l'aria di
* essersi bloccata. Controllare prima di partire rende quel caso un
* messaggio immediato invece di un'attesa senza fine.
*/
zipTroppoGrande(file) {
if (!file || file.size <= this.maxByte) {
return false;
}
const mb = (n) => (n / 1000000).toFixed(0);
this.erroreUpload = `Il pacchetto pesa ${mb(file.size)} MB e supera il limite di `
+ `${mb(this.maxByte)} MB accettato dal server. Per archivi più grandi alza MAX_UPLOAD_MB `
+ `nel .env (serve un redeploy) oppure usa il ripristino dai volumi descritto nel README.`;
return true;
},
fileScelto(event) {
this.erroreUpload = '';
this.uploadPercento = 0;
const file = event.target.files[0];
this.zipSelezionato = file
? `${file.name} (${(file.size / 1000000).toFixed(1)} MB)`
: '';
if (!file) {
return;
}
this.log(`File scelto: ${file.name} — ${(file.size / 1000000).toFixed(1)} MB`);
this.log(`Limite del server: ${(this.maxByte / 1000000).toFixed(0)} MB`);
if (this.zipTroppoGrande(file)) {
this.log(`RIFIUTATO: supera il limite. Nessun caricamento avviato.`, 'errore');
}
window.tmFocusZona('zona-ripristino', false);
},
inviaRipristino(event) {
const form = event.target;
const file = form.querySelector('input[name=backupZip]').files[0];
if (this.zipTroppoGrande(file)) {
this.log(`RIFIUTATO: supera il limite. Nessun caricamento avviato.`, 'errore');
return;
}
this.erroreUpload = '';
this.uploadInCorso = true;
this.uploadPercento = 0;
this.$dispatch('console-apri');
this.log(`Invio a ${form.action}`);
const xhr = new XMLHttpRequest();
xhr.open('POST', form.action);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
// Un caricamento fermo da cinque minuti e' fermo, non lento: senza
// questo non scatterebbe piu' alcun evento e la pagina resterebbe
// in attesa per sempre.
xhr.timeout = 300000;
let ultimaTacca = -1;
xhr.upload.addEventListener('progress', (e) => {
if (!e.lengthComputable) {
return;
}
this.uploadPercento = Math.round((e.loaded / e.total) * 100);
this.ultimoAvanzamento = Date.now();
// Una riga ogni 10%: abbastanza per vedere dove si ferma, non
// tanta da seppellire il resto.
const tacca = Math.floor(this.uploadPercento / 10);
if (tacca > ultimaTacca) {
ultimaTacca = tacca;
this.log(`Caricamento ${this.uploadPercento}% (${(e.loaded / 1000000).toFixed(1)} di ${(e.total / 1000000).toFixed(1)} MB)`);
}
});
xhr.upload.addEventListener('load', () => {
this.log(`Corpo della richiesta inviato per intero. In attesa della risposta del server...`, 'attesa');
});
xhr.addEventListener('load', () => {
this.uploadInCorso = false;
this.fermaBattito();
this.log(`Risposta HTTP ${xhr.status} (${xhr.responseText.length} byte)`);
let payload = {};
try {
payload = JSON.parse(xhr.responseText || '{}');
} catch (e) {
payload = {};
this.log(`La risposta non è JSON. Inizio: ${xhr.responseText.slice(0, 120)}`, 'errore');
}
if (xhr.status >= 200 && xhr.status < 300 && payload.operation_id) {
this.uploadPercento = 100;
this.zipSelezionato = '';
form.reset();
this.log(`Ripristino accodato: operazione ${payload.operation_id}`, 'ok');
this.log(`Da qui l'avanzamento arriva dal server.`);
// Da qui in poi l'avanzamento arriva dal job, non dall'upload.
this.$wire.seguiOperazione(payload.operation_id);
return;
}
// La barra torna a zero: lasciarla dov'era farebbe credere che
// il ripristino sia partito.
this.uploadPercento = 0;
this.erroreUpload = this.messaggioErrore(xhr.status, payload);
this.log(`ERRORE: ${this.erroreUpload}`, 'errore');
});
xhr.addEventListener('error', () => {
this.uploadInCorso = false;
this.uploadPercento = 0;
this.fermaBattito();
this.erroreUpload = `Errore di rete durante il caricamento: connessione interrotta dal `
+ `server. Succede tipicamente quando il pacchetto supera il limite accettato.`;
this.log(`ERRORE DI RETE: il server ha chiuso la connessione senza rispondere.`, 'errore');
});
xhr.addEventListener('timeout', () => {
this.uploadInCorso = false;
this.uploadPercento = 0;
this.fermaBattito();
this.erroreUpload = `Il caricamento non ha risposto entro 5 minuti ed è stato interrotto.`;
this.log(`TIMEOUT dopo 5 minuti senza risposta.`, 'errore');
});
xhr.addEventListener('abort', () => {
this.uploadInCorso = false;
this.uploadPercento = 0;
this.fermaBattito();
this.erroreUpload = `Caricamento annullato.`;
this.log(`Caricamento annullato.`, 'errore');
});
this.avviaBattito();
xhr.send(new FormData(form));
},
/*
* 413 e 419 non hanno un corpo JSON: senza il codice HTTP nel messaggio
* resterebbe un generico errore che non dice cosa fare.
*/
messaggioErrore(status, payload) {
if (payload.message) {
return payload.message;
}
if (payload.errors && payload.errors.backupZip) {
return payload.errors.backupZip[0];
}
if (status === 413) {
return `Il pacchetto è troppo grande per il server (413). Alza MAX_UPLOAD_MB nel .env e rifai il deploy, oppure usa il ripristino dai volumi.`;
}
if (status === 419) {
return `Sessione scaduta durante il caricamento. Ricarica la pagina e riprova.`;
}
if (status === 0) {
return `Il server ha chiuso la connessione durante il caricamento, senza rispondere. Di solito significa che il pacchetto supera il limite accettato.`;
}
return `Errore durante il caricamento del pacchetto (HTTP ${status}).`;
},
}"
{{-- Il poll vive quanto l'operazione: a stato `completed`/`failed` inCorso
diventa falso, l'attributo sparisce dal render successivo e le
richieste si fermano da sole. Vale anche quando l'operazione è di
un'altra zona: qui si polla piano (3s) solo per accorgersi che il
posto si è liberato. --}}
@if($this->inCorso) wire:poll.{{ $this->intervalloPoll }}="aggiorna" @endif>
@if($erroreOperazione !== '')
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900" role="alert">
{{ $erroreOperazione }}
</div>
@endif
{{-- ═══ Zona 1 — Backup ═══════════════════════════════════════════ --}}
<section id="zona-backup"
class="tm-zona bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5
{{ $occupato && $tipo === 'backup' ? 'tm-zona-attiva' : '' }}"
aria-labelledby="titolo-backup">
<div>
<h2 id="titolo-backup" class="text-lg font-semibold text-gray-900">1 &middot; Backup completo (.zip)</h2>
<p class="text-xs text-gray-500 mt-1">
Un unico pacchetto con <strong>tutti</strong> i dati dell&rsquo;applicazione &mdash; utenti compresi &mdash;
e <strong>tutti i file caricati</strong>: PDF dei territori, miniature e logo.
&Egrave; l&rsquo;archivio da conservare per rimettere in piedi l&rsquo;installazione da zero.
</p>
</div>
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
<strong>Dati sensibili.</strong> Il pacchetto include gli utenti con le password (hash), i nomi dei
proclamatori in chiaro e le mappe dei territori. Conservalo in un luogo sicuro e non condividerlo.
</div>
<div class="flex flex-wrap items-center gap-3">
<x-btn variante="primary"
wire:click="avviaBackup"
x-on:click="$dispatch('console-apri')"
data-zona-focus
{{-- :disabled e non @disabled: dentro un tag <x-...> le
direttive Blade non vengono compilate, il tag resta
letterale e il PHP prodotto va in errore di sintassi. --}}
:disabled="$occupato">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
Crea e scarica backup
</x-btn>
{{-- L'archivio resta scaricabile finche' l'operazione e' in vista:
un download interrotto non deve costringere a rifare tutto. --}}
@if($this->archivioPronto && $tipo === 'backup')
<x-btn variante="secondary" :href="route('backup.file', $operationId)">
Scarica di nuovo
</x-btn>
<span class="text-xs text-gray-600">
{{ $op['result']['nome_download'] ?? '' }} &mdash;
{{ number_format(($op['result']['dimensione'] ?? 0) / 1048576, 1, ',', '.') }} MB
</span>
@endif
@if($occupato)
<span class="text-xs text-gray-500">
Un&rsquo;operazione &egrave; gi&agrave; in corso: l&rsquo;avanzamento &egrave; nella console in fondo.
</span>
@else
<span class="text-xs text-gray-500">L&rsquo;archivio si genera in background e compare nella console in fondo.</span>
@endif
</div>
@if($stato === 'failed' && $tipo === 'backup')
<p class="text-sm text-red-800" role="alert">{{ $op['error'] ?? 'Errore non specificato.' }}</p>
@endif
@if($stato === 'completed' && $tipo === 'backup' && ($op['result']['messaggio'] ?? null))
<p class="text-sm text-green-900">{{ $op['result']['messaggio'] }}</p>
@endif
</section>
{{-- ═══ Zona 2 — Ripristino ═══════════════════════════════════════ --}}
<section id="zona-ripristino"
class="tm-zona bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4
{{ $occupato && $tipo === 'restore' ? 'tm-zona-attiva' : '' }}"
aria-labelledby="titolo-ripristino">
<div>
<h2 id="titolo-ripristino" class="text-lg font-semibold text-gray-900">2 &middot; Ripristina da backup</h2>
<p class="text-xs text-gray-500 mt-1">
Carica un pacchetto .zip generato qui sopra. Il ripristino <strong>sostituisce</strong> i dati
gestionali correnti e tutti i file caricati.
</p>
</div>
<div class="rounded-lg border border-red-300 bg-red-50 p-3 text-xs text-red-800">
<strong>Al termine tutti dovranno cambiare password</strong>, amministratore compreso: il pacchetto
contiene le password di ogni account, quindi dopo un ripristino le vecchie non sono pi&ugrave; da
considerarsi segrete. Verrai portato subito sulla schermata di cambio password.
</div>
<form action="{{ route('backup.restore') }}" method="POST" enctype="multipart/form-data" class="space-y-4"
@submit.prevent="if (confirm('Confermi il ripristino? I dati gestionali e i file correnti verranno sostituiti, e tutti gli utenti dovranno impostare una nuova password.')) inviaRipristino($event)">
@csrf
<div>
{{-- La dimensione si controlla gia' alla selezione: sapere
subito che il file e' fuori limite evita di aspettare un
caricamento destinato a essere tagliato. --}}
<label for="backupZip" class="block text-xs font-medium text-gray-700 mb-1">Pacchetto di backup</label>
<input id="backupZip" name="backupZip" type="file" accept=".zip,application/zip"
data-zona-focus
x-on:change="fileScelto($event)"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-red-50 file:text-red-700 hover:file:bg-red-100">
<p class="mt-1 text-xs text-gray-500">
Dimensione massima accettata: {{ \App\Http\Controllers\Settings\BackupController::maxUploadMb() }} MB
(variabile <code class="rounded bg-gray-100 px-1">MAX_UPLOAD_MB</code>).
</p>
<p x-show="erroreUpload" x-cloak class="text-red-600 text-xs mt-1 font-medium" role="alert" x-text="erroreUpload"></p>
</div>
{{-- Caricamento: percentuale reale dell'upload, non del ripristino.
Resta qui e non nella console perche' e' l'unica fase che avviene
nel browser: la console racconta, questa barra misura. --}}
<div x-show="uploadInCorso || uploadPercento > 0" x-cloak class="rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-3">
<div class="flex items-center justify-between gap-3 text-xs text-indigo-800">
<span x-text="uploadInCorso ? 'Caricamento del pacchetto...' : 'Pacchetto caricato'"></span>
<span class="tabular-nums" x-text="uploadPercento + '%'"></span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-indigo-100">
<div class="h-full rounded-full bg-indigo-600 transition-all duration-200" :style="'width:' + uploadPercento + '%'"></div>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<x-btn variante="danger" type="submit"
x-bind:disabled="!zipSelezionato || uploadInCorso || {{ $occupato ? 'true' : 'false' }}">
Ripristina backup
</x-btn>
@if($occupato)
<span class="text-xs text-gray-500">Un&rsquo;altra operazione &egrave; in corso: attendi che finisca.</span>
@endif
</div>
</form>
@if($stato === 'failed' && $tipo === 'restore')
<p class="text-sm text-red-800" role="alert">{{ $op['error'] ?? 'Errore non specificato.' }}</p>
@endif
@if($stato === 'completed' && $tipo === 'restore' && ($op['result']['messaggio'] ?? null))
<p class="text-sm text-green-900">{{ $op['result']['messaggio'] }}</p>
@endif
</section>
</div>