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

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 &darr;
</button>
</section>
</div>