Aggiunge un pannello che registra ogni passaggio del caricamento: file scelto con la sua dimensione, limite del server, avanzamento ogni 10%, codice HTTP della risposta, errori, timeout e interruzioni. Sotto, i log lato server dell'operazione in corso. Serve perche' i modi in cui un caricamento muore sono quasi tutti muti: superato il limite nginx chiude la connessione dopo aver accettato zero byte, e nessun evento porta un messaggio. Senza un diario, la barra si pianta a una percentuale qualsiasi e la pagina non ha niente da dire. Un battito ogni 15 secondi distingue "lento" da "fermo". Le virgolette doppie restano bandite dentro l'x-data: chiudono l'attributo HTML anche dentro un commento, e hanno gia' riversato il codice a video due volte. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
584 lines
30 KiB
PHP
584 lines
30 KiB
PHP
{{--
|
|
Backup, ripristino e azzeramento, con avanzamento reale.
|
|
|
|
La barra legge processed/total dell'operazione in corso, che i job
|
|
aggiornano quando qualcosa e' davvero finito sul disco. Il poll resta
|
|
attivo solo mentre l'operazione e' in coda o in esecuzione: appena chiude,
|
|
si ferma da solo.
|
|
--}}
|
|
@php
|
|
$op = $operazione;
|
|
$tipo = $op['tipo'] ?? null;
|
|
$stato = $op['status'] ?? null;
|
|
$attesaDownload = ($op['fase'] ?? null) === \App\Services\BackupOperationState::FASE_ATTESA_DOWNLOAD;
|
|
|
|
$etichettaTipo = match ($tipo) {
|
|
'backup' => 'Backup in corso',
|
|
'restore' => 'Ripristino in corso',
|
|
'reset' => 'Azzeramento in corso',
|
|
default => 'Operazione in corso',
|
|
};
|
|
@endphp
|
|
|
|
{{--
|
|
ATTENZIONE, prima di toccare l'x-data qui sotto.
|
|
|
|
Un attributo HTML finisce alla PRIMA virgoletta doppia, comprese quelle
|
|
dentro un commento JavaScript. Un banale // un generico "errore" ha gia'
|
|
chiuso l'attributo a meta' e riversato tutto il codice rimanente a video
|
|
come testo, due volte. Dentro questo blocco NON possono comparire
|
|
virgolette doppie, da nessuna parte, neanche nei commenti.
|
|
|
|
Per lo stesso motivo le stringhe usano apici o backtick, e gli apostrofi
|
|
italiani stanno nei backtick, dove non richiedono alcun accorgimento: la
|
|
sequenza \' scritta a mano nel sorgente Blade chiude la stringa invece di
|
|
proteggere l'apostrofo, ed e' l'altro modo in cui questo blocco si e' gia'
|
|
rotto.
|
|
|
|
Un blocco <script> con @script eviterebbe la questione alla radice, ma
|
|
rompe il rilevamento della radice singola di Livewire (errore 500).
|
|
--}}
|
|
<div class="space-y-6"
|
|
x-data="{
|
|
{{-- maxUploadKb() e' in kilobyte; file.size e' in byte. --}}
|
|
maxByte: {{ \App\Http\Controllers\Settings\BackupController::maxUploadKb() }} * 1000,
|
|
zipSelezionato: '',
|
|
uploadInCorso: false,
|
|
uploadPercento: 0,
|
|
erroreUpload: '',
|
|
passwordAzzeramento: '',
|
|
|
|
/*
|
|
* Diario di quel che succede nel browser.
|
|
*
|
|
* Serve perche' i modi in cui un caricamento muore sono spesso muti: il
|
|
* server chiude la connessione e nessun evento porta un messaggio, oppure
|
|
* la barra smette di avanzare senza che nulla lo dichiari. Qui ogni
|
|
* passaggio lascia una riga con l'ora, cosi' il punto esatto in cui si
|
|
* ferma e' leggibile invece che da indovinare, e il testo si puo' copiare
|
|
* e mandare a chi assiste.
|
|
*/
|
|
console: [],
|
|
ultimoAvanzamento: 0,
|
|
battito: null,
|
|
|
|
log(riga) {
|
|
const ora = new Date().toTimeString().slice(0, 8);
|
|
this.console.push(`[${ora}] ${riga}`);
|
|
// Il pannello e' alto qualche riga: il fondo e' la parte che interessa.
|
|
this.$nextTick(() => {
|
|
const el = this.$refs.consoleBox;
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
});
|
|
},
|
|
|
|
get consoleTesto() {
|
|
return this.console.join('\n');
|
|
},
|
|
|
|
/*
|
|
* 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}%)`);
|
|
}
|
|
}, 15000);
|
|
},
|
|
|
|
fermaBattito() {
|
|
if (this.battito) {
|
|
clearInterval(this.battito);
|
|
this.battito = null;
|
|
}
|
|
},
|
|
|
|
azzera() {
|
|
if (!this.passwordAzzeramento) {
|
|
return;
|
|
}
|
|
|
|
if (!confirm('Hai verificato il backup scaricato? Confermando, i dati vengono cancellati definitivamente.')) {
|
|
return;
|
|
}
|
|
|
|
const password = this.passwordAzzeramento;
|
|
// Svuotata subito: se e' sbagliata si ridigita, e intanto non resta
|
|
// nel campo di una scheda lasciata aperta.
|
|
this.passwordAzzeramento = '';
|
|
this.$wire.confermaAzzeramento(password);
|
|
},
|
|
|
|
/**
|
|
* 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.`);
|
|
}
|
|
},
|
|
|
|
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.`);
|
|
return;
|
|
}
|
|
|
|
this.erroreUpload = '';
|
|
this.uploadInCorso = true;
|
|
this.uploadPercento = 0;
|
|
|
|
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...`);
|
|
});
|
|
|
|
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 e' JSON. Inizio: ${xhr.responseText.slice(0, 120)}`);
|
|
}
|
|
|
|
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}`);
|
|
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}`);
|
|
});
|
|
|
|
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.`);
|
|
});
|
|
|
|
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.`);
|
|
});
|
|
|
|
xhr.addEventListener('abort', () => {
|
|
this.uploadInCorso = false;
|
|
this.uploadPercento = 0;
|
|
this.fermaBattito();
|
|
this.erroreUpload = `Caricamento annullato.`;
|
|
this.log(`Caricamento annullato.`);
|
|
});
|
|
|
|
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}).`;
|
|
},
|
|
}"
|
|
x-on:scarica-backup.window="window.location.href = $event.detail.url">
|
|
|
|
@if($erroreOperazione !== '')
|
|
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
|
{{ $erroreOperazione }}
|
|
</div>
|
|
@endif
|
|
|
|
{{-- ─── Pannello di avanzamento ────────────────────────────────── --}}
|
|
@if($operationId && $op)
|
|
<div class="rounded-xl border p-5
|
|
{{ $stato === 'failed' ? 'border-red-300 bg-red-50' : ($stato === 'completed' ? 'border-green-300 bg-green-50' : 'border-indigo-200 bg-indigo-50') }}"
|
|
@if($this->inCorso) wire:poll.{{ $this->intervalloPoll }}="aggiorna" @endif>
|
|
|
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<div class="min-w-0">
|
|
<div class="text-sm font-semibold text-gray-900">
|
|
{{ $stato === 'completed' ? 'Operazione completata' : ($stato === 'failed' ? 'Operazione fallita' : $etichettaTipo) }}
|
|
</div>
|
|
<div class="mt-0.5 text-xs text-gray-600 truncate">
|
|
{{ $op['fase'] ?? '' }}
|
|
@if(($op['total'] ?? 0) > 0 && $stato !== 'completed')
|
|
— {{ $op['processed'] ?? 0 }} / {{ $op['total'] }}
|
|
@endif
|
|
</div>
|
|
</div>
|
|
<div class="text-2xl font-bold tabular-nums {{ $stato === 'failed' ? 'text-red-700' : 'text-indigo-700' }}">
|
|
{{ $this->percentuale }}%
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-3 h-2.5 overflow-hidden rounded-full bg-white/70">
|
|
<div class="h-full rounded-full transition-all duration-300
|
|
{{ $stato === 'failed' ? 'bg-red-600' : ($stato === 'completed' ? 'bg-green-600' : 'bg-indigo-600') }}"
|
|
style="width: {{ $this->percentuale }}%"></div>
|
|
</div>
|
|
|
|
@if($attesaDownload)
|
|
<div class="mt-3 rounded-lg border-2 border-amber-400 bg-amber-50 px-4 py-3 space-y-3">
|
|
<p class="text-sm text-amber-900">
|
|
<strong>L’archivio è pronto e il download è partito.</strong>
|
|
Non è stato cancellato ancora nulla: l’azzeramento aspetta la tua conferma.
|
|
</p>
|
|
<p class="text-xs text-amber-800">
|
|
Apri il file scaricato e verifica che sia integro. Il server sa di aver <em>spedito</em>
|
|
l’archivio, non può sapere se è arrivato intero: quella verifica tocca a te,
|
|
ed è l’ultima possibilità di tornare indietro.
|
|
</p>
|
|
|
|
{{-- La password sta in Alpine, non in una proprietà del
|
|
componente: le proprietà pubbliche di Livewire tornano
|
|
al browser dentro lo snapshot a ogni risposta, e una
|
|
password non ha motivo di fare quel viaggio. Passata
|
|
come parametro del metodo, viaggia solo in andata. --}}
|
|
<div class="max-w-xs">
|
|
<label for="passwordConferma" class="block text-xs font-medium text-amber-900">
|
|
Conferma con la tua password
|
|
</label>
|
|
<input type="password" id="passwordConferma" autocomplete="current-password"
|
|
x-model="passwordAzzeramento"
|
|
x-on:keydown.enter.prevent="azzera()"
|
|
class="mt-1 block w-full rounded-lg border-amber-300 shadow-sm focus:ring-red-500 focus:border-red-500 text-sm">
|
|
@error('passwordConferma') <p class="mt-1 text-xs font-medium text-red-700">{{ $message }}</p> @enderror
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<a href="{{ route('backup.file', $operationId) }}"
|
|
style="display:inline-flex;align-items:center;gap:6px;background:#fff;color:#92400e;border:1px solid #d97706;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:500;text-decoration:none;">
|
|
Scarica di nuovo
|
|
</a>
|
|
|
|
<button type="button"
|
|
x-on:click="azzera()"
|
|
x-bind:disabled="!passwordAzzeramento"
|
|
x-bind:style="!passwordAzzeramento ? 'opacity:.5;cursor:not-allowed;' : ''"
|
|
style="display:inline-flex;align-items:center;gap:6px;background:#b91c1c;color:#fff;border:1px solid #7f1d1d;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:600;">
|
|
Ho salvato il backup, azzera tutto
|
|
</button>
|
|
|
|
<button type="button" wire:click="annullaAzzeramento" class="text-xs text-gray-700 underline">
|
|
Annulla l’azzeramento
|
|
</button>
|
|
</div>
|
|
|
|
@error('confermaReset') <p class="text-xs font-medium text-red-700">{{ $message }}</p> @enderror
|
|
</div>
|
|
@endif
|
|
|
|
@if($stato === 'failed')
|
|
<p class="mt-3 text-sm text-red-800">{{ $op['error'] ?? 'Errore non specificato.' }}</p>
|
|
@endif
|
|
|
|
@if($stato === 'completed' && ($op['result']['messaggio'] ?? null))
|
|
<p class="mt-3 text-sm text-green-900">{{ $op['result']['messaggio'] }}</p>
|
|
@endif
|
|
|
|
@if($stato === 'completed' && $tipo === 'backup' && ($op['result']['file'] ?? null))
|
|
<div class="mt-3 flex flex-wrap items-center gap-3">
|
|
<a href="{{ route('backup.file', $operationId) }}"
|
|
style="display:inline-flex;align-items:center;gap:6px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:500;text-decoration:none;">
|
|
Scarica di nuovo
|
|
</a>
|
|
<span class="text-xs text-gray-600">
|
|
{{ $op['result']['nome_download'] ?? '' }} —
|
|
{{ number_format(($op['result']['dimensione'] ?? 0) / 1048576, 1, ',', '.') }} MB
|
|
</span>
|
|
</div>
|
|
@endif
|
|
|
|
@if(!empty($op['logs']))
|
|
<details class="mt-3">
|
|
<summary class="cursor-pointer text-xs font-medium text-gray-700">Dettaglio</summary>
|
|
<textarea readonly rows="8" class="mt-2 block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-2 font-mono text-xs leading-5 text-gray-100">{{ implode(PHP_EOL, $op['logs']) }}</textarea>
|
|
</details>
|
|
@endif
|
|
|
|
@if(in_array($stato, ['completed', 'failed'], true))
|
|
<button type="button" wire:click="chiudi" class="mt-3 text-xs text-gray-600 underline">Chiudi</button>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
{{-- ─── Backup ─────────────────────────────────────────────────── --}}
|
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-900">Backup completo (.zip)</h2>
|
|
<p class="text-xs text-gray-500 mt-1">
|
|
Un unico pacchetto con <strong>tutti</strong> i dati dell’applicazione — utenti compresi —
|
|
e <strong>tutti i file caricati</strong>: PDF dei territori, miniature e logo.
|
|
E’ l’archivio da conservare per rimettere in piedi l’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-col gap-3 sm:flex-row sm:items-center">
|
|
<button type="button" wire:click="avviaBackup" @disabled($this->inCorso)
|
|
style="display:inline-flex;align-items:center;gap:6px;justify-content:center;min-height:40px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:500;"
|
|
@class(['opacity-50 cursor-not-allowed' => $this->inCorso])>
|
|
<svg style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor"><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
|
|
</button>
|
|
<p class="text-xs text-gray-500">L’archivio si genera in background: puoi seguire l’avanzamento qui sopra.</p>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ─── Ripristino ─────────────────────────────────────────────── --}}
|
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-900">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 piu’ 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. --}}
|
|
<input name="backupZip" type="file" accept=".zip,application/zip"
|
|
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.
|
|
</p>
|
|
<p x-show="erroreUpload" x-cloak class="text-red-600 text-xs mt-1 font-medium" x-text="erroreUpload"></p>
|
|
</div>
|
|
|
|
{{-- Caricamento: percentuale reale dell'upload, non del ripristino. --}}
|
|
<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>
|
|
|
|
@php($bloccato = $this->inCorso)
|
|
<button type="submit"
|
|
x-bind:disabled="!zipSelezionato || uploadInCorso || {{ $bloccato ? 'true' : 'false' }}"
|
|
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#dc2626;color:#fff;border:1px solid #991b1b;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:500;"
|
|
x-bind:style="(!zipSelezionato || uploadInCorso || {{ $bloccato ? 'true' : 'false' }}) ? 'opacity:.5;cursor:not-allowed;' : ''">
|
|
Ripristina backup
|
|
</button>
|
|
@if($bloccato)
|
|
<p class="text-xs text-gray-500">Un’altra operazione è in corso: attendi che finisca.</p>
|
|
@endif
|
|
</form>
|
|
|
|
{{-- Console: il diario del browser e quello del server, uno sotto
|
|
l'altro. E' l'unico posto da cui si capisce dove un caricamento si
|
|
e' fermato, e il testo si seleziona e si copia per mandarlo a chi
|
|
assiste. --}}
|
|
<div class="border-t border-gray-200 pt-4">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<h3 class="text-sm font-semibold text-gray-900">Console</h3>
|
|
<div class="flex items-center gap-3 text-xs">
|
|
<button type="button" x-on:click="console = []" class="text-gray-600 underline">Pulisci</button>
|
|
<button type="button"
|
|
x-on:click="navigator.clipboard.writeText(consoleTesto + String.fromCharCode(10) + $refs.logServer?.value)"
|
|
class="text-gray-600 underline">Copia tutto</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-2">
|
|
<div class="text-xs font-medium text-gray-700">Browser</div>
|
|
<textarea readonly rows="8" x-ref="consoleBox" x-text="consoleTesto || 'In attesa. Scegli un pacchetto per iniziare.'"
|
|
class="mt-1 block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-2 font-mono text-xs leading-5 text-gray-100"></textarea>
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
<div class="text-xs font-medium text-gray-700">Server</div>
|
|
<textarea readonly rows="8" x-ref="logServer"
|
|
class="mt-1 block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-2 font-mono text-xs leading-5 text-gray-100">{{ !empty($op['logs']) ? implode(PHP_EOL, $op['logs']) : 'Nessuna operazione sul server. Compare qui appena il pacchetto viene accettato.' }}</textarea>
|
|
</div>
|
|
|
|
<p class="mt-2 text-xs text-gray-500">
|
|
Limite di caricamento configurato:
|
|
<strong>{{ \App\Http\Controllers\Settings\BackupController::maxUploadMb() }} MB</strong>
|
|
(variabile <code class="rounded bg-gray-100 px-1">MAX_UPLOAD_MB</code>).
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ─── Zona pericolosa ────────────────────────────────────────── --}}
|
|
<div class="rounded-xl border-2 border-red-300 bg-red-50/40 p-6 space-y-4">
|
|
<div class="flex items-start gap-3">
|
|
<svg class="h-6 w-6 flex-none text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 00-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/></svg>
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-red-900">Zona pericolosa</h2>
|
|
<p class="text-xs text-red-800 mt-1">Operazioni irreversibili. Leggi prima di premere.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rounded-lg border border-red-300 bg-white p-4 space-y-3">
|
|
<h3 class="text-sm font-semibold text-gray-900">Azzera tutto e riparti da zero</h3>
|
|
|
|
<p class="text-xs text-gray-600">
|
|
Riporta l’applicazione allo stato di installazione appena eseguita. In ordine:
|
|
</p>
|
|
<ol class="text-xs text-gray-600 list-decimal list-inside space-y-1">
|
|
<li><strong>Viene creato e scaricato un backup completo</strong> di tutto ciò che c’è adesso.</li>
|
|
<li>L’operazione si ferma e aspetta: <strong>controlli il file scaricato, digiti la tua password e confermi</strong>. Fino a quel clic non viene toccato nulla, e puoi annullare.</li>
|
|
<li>Vengono eliminati territori, proclamatori, assegnazioni, campagne, zone, tipologie, anni di servizio, il registro attività e <strong>tutti i file caricati</strong>.</li>
|
|
<li>Le impostazioni tornano ai valori di default e i ruoli vengono riseminati.</li>
|
|
<li><strong>Tutti gli altri utenti vengono eliminati.</strong> Resta solo il tuo account di amministratore, con la password attuale — come un’installazione nuova, che ha un amministratore solo.</li>
|
|
</ol>
|
|
|
|
<div class="rounded-lg border border-red-200 bg-red-50 p-3 text-xs text-red-800">
|
|
Il backup scaricato è l’<strong>unico</strong> modo per tornare indietro. Verifica di averlo
|
|
salvato prima di chiudere il browser.
|
|
</div>
|
|
|
|
<div>
|
|
<label for="confermaReset" class="block text-xs font-medium text-gray-700">
|
|
Per confermare, scrivi <code class="rounded bg-gray-100 px-1 py-0.5 font-mono">{{ \App\Livewire\Settings\BackupManager::CONFERMA_AZZERAMENTO }}</code>
|
|
</label>
|
|
<input wire:model="confermaReset" type="text" id="confermaReset" autocomplete="off"
|
|
class="mt-1 block w-full max-w-xs rounded-lg border-gray-300 shadow-sm focus:ring-red-500 focus:border-red-500 text-sm font-mono">
|
|
@error('confermaReset') <p class="text-red-600 text-xs mt-1">{{ $message }}</p> @enderror
|
|
</div>
|
|
|
|
<button type="button"
|
|
wire:click="avviaAzzeramento"
|
|
wire:confirm="Ultima conferma. Verrà scaricato un backup e poi l'applicazione sarà azzerata. L'operazione non è reversibile."
|
|
@disabled($this->inCorso)
|
|
style="display:inline-flex;align-items:center;gap:6px;justify-content:center;min-height:40px;background:#b91c1c;color:#fff;border:1px solid #7f1d1d;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:600;"
|
|
@class(['opacity-50 cursor-not-allowed' => $this->inCorso])>
|
|
<svg style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.87 12.14A2 2 0 0116.14 21H7.86a2 2 0 01-1.99-1.86L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3M4 7h16"/></svg>
|
|
Scarica backup e azzera tutto
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|