346 lines
14 KiB
PHP
346 lines
14 KiB
PHP
{{--
|
|
Console unica delle operazioni: backup, ripristino, azzeramento, import PDF,
|
|
import XML. Agganciata in fondo alla finestra, si apre da sé quando qualcosa
|
|
parte e scorre da sola mentre arrivano le righe.
|
|
|
|
Perché una sola. Prima questa pagina aveva cinque riquadri di log — due nel
|
|
backup (browser e server), uno per l'upload dei PDF, uno per lo stato
|
|
dell'import, più il "Dettaglio" del pannello di avanzamento — e nessuno
|
|
diceva la storia per intero: un ripristino cominciava nel riquadro del
|
|
browser e finiva in quello del server, in due punti diversi della pagina, e
|
|
per capire dove si era fermato bisognava guardarli tutti. Qui la cronologia è
|
|
una, in ordine di tempo, con la sorgente accanto a ogni riga.
|
|
|
|
Perché è fissa. Quasi tutti i modi in cui un caricamento muore sono muti, e
|
|
l'unico indizio è dove si è interrotto il flusso di righe: un log che esce
|
|
dallo schermo appena si scorre la pagina non serve a niente proprio nel
|
|
momento in cui servirebbe.
|
|
|
|
Da dove arrivano le righe:
|
|
- `console-riga` eventi del browser (scelta file, avanzamento
|
|
dell'upload, codici HTTP, timeout, interruzioni)
|
|
- `console-operazione` code di log del server, rimandate per intero a ogni
|
|
poll da BackupManager e XmlExchange; qui si tiene il
|
|
conto di quante sono già state assorbite, così una
|
|
riga non compare due volte
|
|
--}}
|
|
|
|
{{--
|
|
ATTENZIONE — il JavaScript sta qui, in un <script>, e NON dentro l'attributo
|
|
x-data.
|
|
|
|
Un attributo HTML finisce alla prima virgoletta doppia, comprese quelle
|
|
dentro un commento: questa pagina si è già rotta due volte così, riversando
|
|
a video il codice come testo. Dentro un <script> le virgolette e gli
|
|
apostrofi non hanno alcuna limitazione.
|
|
|
|
Non è in resources/js/app.js perché quel bundle è un modulo ES, che il
|
|
browser rimanda a dopo il parsing del documento, mentre Alpine arriva col
|
|
pacchetto di Livewire in fondo al <body> ed è già avviato a quel punto: una
|
|
Alpine.data() registrata dopo l'avvio non viene applicata agli elementi già
|
|
inizializzati. Uno <script> classico qui viene eseguito prima, sempre.
|
|
|
|
Non usa @script (la direttiva di Livewire) perché rompe il rilevamento della
|
|
radice singola del componente e restituisce 500 — provato.
|
|
--}}
|
|
<script>
|
|
window.tmConsoleOperazioni = function () {
|
|
return {
|
|
righe: [],
|
|
aperta: false,
|
|
|
|
/* Lo scorrimento automatico si sospende se si torna indietro a
|
|
leggere: trascinare l'utente in fondo mentre sta guardando una
|
|
riga di dieci secondi prima è il modo più rapido di rendere
|
|
inutile un log che scorre. */
|
|
inFondo: true,
|
|
|
|
operazione: { etichetta: '', fase: '', percento: 0, stato: '' },
|
|
|
|
/* Tetto alla cronologia: un import di qualche migliaio di PDF
|
|
riempirebbe la memoria della scheda con nodi che nessuno legge. */
|
|
maxRighe: 600,
|
|
|
|
init() {
|
|
this.log('Console pronta: le operazioni compaiono qui.', 'info', 'app');
|
|
},
|
|
|
|
get haOperazione() {
|
|
return this.operazione.etichetta !== '';
|
|
},
|
|
|
|
get etichettaStato() {
|
|
if (this.operazione.stato === 'failed') return 'fallita';
|
|
if (this.operazione.stato === 'completed') return 'completata';
|
|
return 'in corso';
|
|
},
|
|
|
|
apri() {
|
|
if (this.aperta) return;
|
|
this.aperta = true;
|
|
this.inFondo = true;
|
|
this.$nextTick(() => this.scorriInFondo());
|
|
},
|
|
|
|
chiudi() {
|
|
this.aperta = false;
|
|
},
|
|
|
|
commuta() {
|
|
this.aperta ? this.chiudi() : this.apri();
|
|
},
|
|
|
|
log(testo, livello, sorgente, ora) {
|
|
this.righe.push({
|
|
ora: ora || new Date().toTimeString().slice(0, 8),
|
|
testo: testo,
|
|
livello: livello || 'info',
|
|
sorgente: sorgente || 'browser',
|
|
});
|
|
|
|
if (this.righe.length > this.maxRighe) {
|
|
this.righe.splice(0, this.righe.length - this.maxRighe);
|
|
}
|
|
|
|
this.$nextTick(() => this.scorriInFondo());
|
|
},
|
|
|
|
/* Riga singola dal browser. */
|
|
aggiungi(dettaglio) {
|
|
if (!dettaglio || !dettaglio.testo) return;
|
|
|
|
this.apri();
|
|
this.log(dettaglio.testo, dettaglio.livello, dettaglio.sorgente || 'browser');
|
|
},
|
|
|
|
/* Righe nuove dal server, più lo stato dell'operazione.
|
|
|
|
Sono già solo le nuove: il conto di quante sono state spedite lo
|
|
tiene il componente Livewire (SegueOperazioneBackup::logSpediti),
|
|
così la coda intera non viaggia dentro ogni risposta — sessanta
|
|
righe, poco meno di due volte al secondo. Per ogni operazione
|
|
esiste un solo componente che spedisce, quindi qui non serve
|
|
deduplicare. */
|
|
assorbi(dettaglio) {
|
|
if (!dettaglio) return;
|
|
|
|
const sorgente = dettaglio.sorgente || 'server';
|
|
const nuove = Array.isArray(dettaglio.righe) ? dettaglio.righe : [];
|
|
|
|
if (nuove.length) {
|
|
this.apri();
|
|
nuove.forEach((riga) => {
|
|
this.log(this.testoDi(riga), this.livelloDi(riga), sorgente, this.oraDi(riga));
|
|
});
|
|
}
|
|
|
|
if (dettaglio.etichetta !== undefined) {
|
|
this.operazione = {
|
|
etichetta: dettaglio.etichetta || '',
|
|
fase: dettaglio.fase || '',
|
|
percento: Number(dettaglio.percento) || 0,
|
|
stato: dettaglio.stato || '',
|
|
};
|
|
|
|
if (this.operazione.etichetta) {
|
|
this.apri();
|
|
}
|
|
}
|
|
},
|
|
|
|
/* Le righe del server arrivano già con "[HH:MM:SS] " davanti: quella
|
|
è l'ora del server, che è quella giusta da mostrare — l'ora del
|
|
browser sarebbe un'altra macchina. */
|
|
oraDi(riga) {
|
|
const trovato = String(riga).match(/^\[(\d{2}:\d{2}:\d{2})\]/);
|
|
|
|
return trovato ? trovato[1] : new Date().toTimeString().slice(0, 8);
|
|
},
|
|
|
|
testoDi(riga) {
|
|
return String(riga).replace(/^\[\d{2}:\d{2}:\d{2}\]\s*/, '');
|
|
},
|
|
|
|
livelloDi(riga) {
|
|
const testo = String(riga);
|
|
|
|
if (/ERRORE|FALLIT|non riuscit|rifiutat/i.test(testo)) return 'errore';
|
|
if (/complet|riuscit|pronto|creato/i.test(testo)) return 'ok';
|
|
if (/attesa|in coda/i.test(testo)) return 'attesa';
|
|
|
|
return 'info';
|
|
},
|
|
|
|
/* Svuota solo la vista: le righe già scritte se ne vanno, quelle che
|
|
arriveranno da qui in poi continuano a comparire. Il conto di cosa
|
|
è stato spedito lo tiene il server, quindi pulire non fa
|
|
ricomparire il passato né interrompe il flusso. */
|
|
pulisci() {
|
|
this.righe = [];
|
|
this.log('Cronologia svuotata.', 'info', 'app');
|
|
},
|
|
|
|
testoIntero() {
|
|
return this.righe
|
|
.map((r) => '[' + r.ora + '] ' + r.sorgente + ': ' + r.testo)
|
|
.join('\n');
|
|
},
|
|
|
|
copia() {
|
|
const testo = this.testoIntero();
|
|
|
|
if (!navigator.clipboard) {
|
|
this.log('Gli appunti non sono disponibili su una connessione non sicura: seleziona il testo a mano.', 'errore', 'app');
|
|
return;
|
|
}
|
|
|
|
navigator.clipboard.writeText(testo).then(
|
|
() => this.log('Cronologia copiata negli appunti (' + this.righe.length + ' righe).', 'ok', 'app'),
|
|
() => this.log('Copia negli appunti rifiutata dal browser.', 'errore', 'app')
|
|
);
|
|
},
|
|
|
|
scorriInFondo() {
|
|
const corpo = this.$refs.corpo;
|
|
|
|
if (!corpo || !this.inFondo) return;
|
|
|
|
corpo.scrollTop = corpo.scrollHeight;
|
|
},
|
|
|
|
aggiornaAncoraggio() {
|
|
const corpo = this.$refs.corpo;
|
|
|
|
if (!corpo) return;
|
|
|
|
/* Tolleranza di 24px: con lo scorrimento fluido la posizione non
|
|
arriva mai esattamente al fondo, e senza margine lo
|
|
scorrimento automatico si spegnerebbe da solo. */
|
|
this.inFondo = (corpo.scrollHeight - corpo.scrollTop - corpo.clientHeight) < 24;
|
|
},
|
|
|
|
riaggancia() {
|
|
this.inFondo = true;
|
|
this.scorriInFondo();
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Porta la pagina su una zona e la fa lampeggiare.
|
|
*
|
|
* È il "focus" della pagina: premuto un pulsante, o scelta una voce
|
|
* dell'indice, deve essere evidente *dove* è finita l'attenzione, invece di
|
|
* dover cercare cosa è cambiato. Il focus da tastiera si sposta solo quando
|
|
* la richiesta è di navigazione (l'indice): spostarlo anche a ogni azione
|
|
* strapperebbe il cursore da sotto le dita di chi ha appena premuto.
|
|
*/
|
|
window.tmFocusZona = function (id, spostaFocus) {
|
|
const zona = document.getElementById(id);
|
|
|
|
if (!zona) return;
|
|
|
|
zona.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
|
zona.classList.remove('tm-zona-evidenziata');
|
|
/* Forza un reflow: senza, il browser considera la classe già applicata e
|
|
su due clic ravvicinati l'animazione non si ripete. */
|
|
void zona.offsetWidth;
|
|
zona.classList.add('tm-zona-evidenziata');
|
|
|
|
window.setTimeout(function () {
|
|
zona.classList.remove('tm-zona-evidenziata');
|
|
}, 1700);
|
|
|
|
if (spostaFocus) {
|
|
const primo = zona.querySelector('[data-zona-focus]');
|
|
|
|
if (primo) {
|
|
primo.focus({ preventScroll: true });
|
|
}
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<div wire:ignore
|
|
x-data="tmConsoleOperazioni()"
|
|
x-on:console-riga.window="aggiungi($event.detail)"
|
|
x-on:console-operazione.window="assorbi($event.detail)"
|
|
x-on:console-apri.window="apri()"
|
|
{{-- Il download automatico dell'archivio si ascolta QUI e in nessun altro
|
|
posto. È un evento su window: due componenti che lo ascoltassero
|
|
entrambi assegnerebbero window.location.href due volte, e il browser
|
|
scaricherebbe il pacchetto due volte. La console è l'unico elemento
|
|
della pagina che c'è sempre, qualunque zona abbia avviato
|
|
l'operazione. --}}
|
|
x-on:scarica-backup.window="log('Scaricamento del pacchetto avviato.', 'ok', 'app'); window.location.href = $event.detail.url">
|
|
|
|
{{-- Spaziatore: il pannello è a posizione fissa, quindi senza questo
|
|
coprirebbe l'ultima zona della pagina — che è quella pericolosa. --}}
|
|
<div aria-hidden="true" x-bind:style="'height:' + (aperta ? 360 : 60) + 'px'"></div>
|
|
|
|
<section class="tm-console" aria-label="Console delle operazioni">
|
|
<div class="tm-console-testata">
|
|
<button type="button" class="tm-console-titolo" x-on:click="commuta()"
|
|
aria-controls="tm-console-corpo"
|
|
x-bind:aria-expanded="aperta ? 'true' : 'false'">
|
|
<span aria-hidden="true" x-text="aperta ? '▾' : '▸'"></span>
|
|
<span>Console</span>
|
|
<span style="color:#9ca3af;font-weight:400"
|
|
x-show="!aperta && righe.length > 0" x-cloak
|
|
x-text="'(' + righe.length + ')'"></span>
|
|
</button>
|
|
|
|
{{-- Stato dell'operazione. aria-live sta solo qui e non sul corpo:
|
|
un log che si aggiorna ogni 750ms letto ad alta voce riga per
|
|
riga sarebbe inascoltabile. --}}
|
|
<div style="display:flex;align-items:center;gap:10px;flex:1;min-width:0"
|
|
x-show="haOperazione" x-cloak>
|
|
<span style="font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"
|
|
aria-live="polite">
|
|
<span style="font-weight:600" x-text="operazione.etichetta"></span>
|
|
<span style="color:#9ca3af" x-show="operazione.fase" x-cloak
|
|
x-text="' · ' + operazione.fase"></span>
|
|
</span>
|
|
|
|
<div class="tm-console-barra"
|
|
x-bind:class="{
|
|
'tm-console-barra-ok': operazione.stato === 'completed',
|
|
'tm-console-barra-errore': operazione.stato === 'failed'
|
|
}"
|
|
role="progressbar" aria-label="Avanzamento operazione"
|
|
aria-valuemin="0" aria-valuemax="100"
|
|
x-bind:aria-valuenow="operazione.percento">
|
|
<div x-bind:style="'width:' + operazione.percento + '%'"></div>
|
|
</div>
|
|
|
|
<span style="font-size:12px;font-variant-numeric:tabular-nums;min-width:38px;text-align:right"
|
|
x-text="operazione.percento + '%'"></span>
|
|
</div>
|
|
|
|
<div style="display:flex;align-items:center;gap:6px;margin-left:auto">
|
|
<button type="button" class="tm-console-btn" x-on:click="pulisci()">Pulisci</button>
|
|
<button type="button" class="tm-console-btn" x-on:click="copia()">Copia</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="tm-console-corpo" class="tm-console-corpo" x-ref="corpo"
|
|
x-show="aperta" x-cloak
|
|
x-on:scroll="aggiornaAncoraggio()">
|
|
<template x-for="(riga, indice) in righe" :key="indice">
|
|
<div class="tm-console-riga" x-bind:class="'tm-console-riga-' + riga.livello">
|
|
<span class="tm-console-ora" x-text="riga.ora"></span>
|
|
<span class="tm-console-sorgente" x-text="riga.sorgente"></span>
|
|
<span class="tm-console-testo" x-text="riga.testo"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<button type="button" class="tm-console-torna"
|
|
x-show="aperta && !inFondo" x-cloak
|
|
x-on:click="riaggancia()">
|
|
Torna in fondo ↓
|
|
</button>
|
|
</section>
|
|
</div>
|