[FEAT] Inventario: esportazione CSV in formato import-compatibile (#441)

Nuovo pulsante "Esporta" in assets.html: scarica l'intero inventario
(tutte le pagine) in CSV con le stesse colonne riconosciute
dall'import (name, asset_type, criticality, data_classification,
internet_facing, dependencies_count, regulated, external_ref, vendor,
location). I criteri data_classification/internet_facing/
dependencies_count/regulated sono ricostruiti da relevance_criteria con
mappatura reversibile (round-trip stabile, verificato 100/100 casi).
Parser import reso quote-aware (RFC-style) per round-trip lossless di
nomi con virgola; retro-compatibile con CSV non quotato.
help.js + cache-buster (?v=20260625b) su tutte le pagine. version 1.24.14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-25 09:13:22 +02:00
co-authored by Claude Opus 4.8
parent b33e0d051e
commit 87fc5bf959
31 changed files with 117 additions and 32 deletions
+80 -3
View File
@@ -283,6 +283,7 @@
<span class="text-muted" style="font-size:0.8rem; margin-right:8px;">Art. 21.2.i NIS2</span>
<button class="btn btn-secondary" onclick="showDependencyMap()" title="Mappa delle dipendenze tra i beni">Mappa Dipendenze</button>
<button class="btn btn-secondary" onclick="openImportModal()" title="Importa beni da CSV/CMDB">Importa</button>
<button class="btn btn-secondary" onclick="exportAssets()" title="Esporta l'inventario in CSV (stesso formato dell'importazione)">Esporta</button>
<button class="btn btn-primary" onclick="showCreateAssetModal()">+ Nuovo bene</button>
</div>
</header>
@@ -375,7 +376,7 @@
</script>
<script src="/js/common-bi.js?v=20260624f"></script>
<script src="/js/i18n.js?v=20260631"></script>
<script src="/js/help.js?v=20260625a"></script>
<script src="/js/help.js?v=20260625b"></script>
<script>
// ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
@@ -1089,13 +1090,32 @@ function impFile(e){
const f=e.target.files[0]; if(!f)return;
const r=new FileReader(); r.onload=()=>{document.getElementById('imp-csv').value=r.result;}; r.readAsText(f);
}
// Split di una riga CSV gestendo virgolette ed escape (""), così nomi con
// virgola/virgolette importano correttamente (round-trip con l'export). Le righe
// non quotate si comportano come prima (retro-compatibile).
function splitCsvLine(line){
const out=[]; let cur=''; let inQ=false;
for(let i=0;i<line.length;i++){
const ch=line[i];
if(inQ){
if(ch==='"'){ if(line[i+1]==='"'){ cur+='"'; i++; } else inQ=false; }
else cur+=ch;
} else {
if(ch==='"') inQ=true;
else if(ch===',') { out.push(cur); cur=''; }
else cur+=ch;
}
}
out.push(cur);
return out;
}
function parseCsv(text){
const lines=text.trim().split(/\r?\n/).filter(l=>l.trim());
if(lines.length<2)return [];
const headers=lines[0].split(',').map(h=>h.trim().toLowerCase());
const headers=splitCsvLine(lines[0]).map(h=>h.trim().toLowerCase());
const truthy=v=>/^(1|true|si|sì|yes|y)$/i.test((v||'').trim());
return lines.slice(1).map(line=>{
const cells=line.split(','); const o={};
const cells=splitCsvLine(line); const o={};
headers.forEach((h,i)=>{ o[h]=(cells[i]||'').trim(); });
const a={name:o.name, asset_type:o.asset_type, criticality:o.criticality,
data_classification:o.data_classification, vendor:o.vendor, location:o.location,
@@ -1120,6 +1140,63 @@ async function doImport(){
}catch(e){showNotification('Errore di connessione','error');}
}
/* ── Export inventario (stesso formato dell'import) #441 ──── */
// Stesse colonne riconosciute dall'import: re-importando il file si ricostruisce
// l'inventario (lo scoring di rilevanza NIS2 viene ricalcolato in import).
const EXPORT_COLS = ['name','asset_type','criticality','data_classification','internet_facing','dependencies_count','regulated','external_ref','vendor','location'];
function csvCell(v){
const s = (v===null||v===undefined) ? '' : String(v).replace(/[\r\n]+/g,' ');
return /[",]/.test(s) ? '"'+s.replace(/"/g,'""')+'"' : s;
}
// Ricostruisce le colonne di import dai criteri di scoring salvati (relevance_criteria).
// Le coppie sono reversibili: re-importando si riottengono gli stessi criteri.
function assetToExportRow(a){
let crit = {};
try { crit = (typeof a.relevance_criteria === 'string') ? JSON.parse(a.relevance_criteria||'{}') : (a.relevance_criteria||{}); } catch(e){ crit = {}; }
const dcMap = { gdpr_art9:'special', personal_large:'personal', personal_fin:'financial', confidential:'confidential', public:'public' };
const depMap = { ge5_critical:5, n3_4_critical:3, n2_critical:2, n1_critical:1, none:0 };
const c4 = crit.c4_dependencies;
return {
name: a.name || '',
asset_type: a.asset_type || '',
criticality: a.criticality || '',
data_classification: dcMap[crit.c3_data_processed] || '',
internet_facing: /^internet/.test(crit.c5_exposure || '') ? 'true' : 'false',
dependencies_count: (c4 in depMap) ? depMap[c4] : 0,
regulated: (crit.c6_regulatory && crit.c6_regulatory !== 'none') ? 'true' : 'false',
external_ref: a.external_ref || '',
vendor: a.vendor || '',
location: a.location || ''
};
}
async function exportAssets(){
showNotification('Preparazione esportazione…','info');
let items=[], page=1, total=Infinity;
try{
while(items.length < total){
const res = await api.listAssets({ per_page:200, page });
if(!res || !res.success){ showNotification((res&&res.message)||'Errore durante l\'esportazione','error'); return; }
const d = res.data || {};
const batch = d.items || (Array.isArray(d) ? d : []);
total = (typeof d.total === 'number') ? d.total : batch.length;
items = items.concat(batch);
if(!batch.length || page>50) break;
page++;
}
}catch(e){ showNotification('Errore di connessione durante l\'esportazione','error'); return; }
if(!items.length){ showNotification('Inventario vuoto: niente da esportare','warning'); return; }
const lines = [ EXPORT_COLS.join(',') ];
items.map(assetToExportRow).forEach(r => lines.push(EXPORT_COLS.map(c => csvCell(r[c])).join(',')));
const csv = '' + lines.join('\r\n'); // BOM per Excel
const stamp = new Date().toISOString().slice(0,10);
const blob = new Blob([csv], { type:'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const el = document.createElement('a');
el.href = url; el.download = `inventario-nis2-${stamp}.csv`;
document.body.appendChild(el); el.click(); el.remove(); URL.revokeObjectURL(url);
showNotification(`Esportati ${items.length} beni nel formato di importazione`,'success');
}
</script>
</body>
</html>