[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
+1 -1
View File
@@ -1095,7 +1095,7 @@
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
+1 -1
View File
@@ -167,7 +167,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 check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
+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>
+1 -1
View File
@@ -156,7 +156,7 @@
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
'use strict';
/*
+1 -1
View File
@@ -356,7 +356,7 @@
<script>if (window.bootstrap && bootstrap.loadFonts) { bootstrap.loadFonts('vendor/bootstrap-italia/dist/fonts'); }</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>
if (!checkAuth()) throw new Error('Not authenticated');
+1 -1
View File
@@ -188,7 +188,7 @@
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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 src="/js/competenze.js?v=20260617"></script>
</body>
</html>
+1 -1
View File
@@ -141,7 +141,7 @@
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
'use strict';
/* Controlli periodici (control monitoring §9.1/A.8.16): controlli ricorrenti + esecuzioni (evidenza) + NC/AC. */
+1 -1
View File
@@ -156,7 +156,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 check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
+1 -1
View File
@@ -1154,7 +1154,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>
// La guida è accessibile anche da non autenticati (utile per onboarding)
// ma se sei loggato carichi sidebar + i18n normalmente. Stessa logica
+1 -1
View File
@@ -364,7 +364,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');
+1 -1
View File
@@ -144,7 +144,7 @@
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
'use strict';
/*
+1 -1
View File
@@ -197,7 +197,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 src="/js/isms.js?v=20260620b"></script>
</body>
</html>
+8
View File
@@ -952,6 +952,14 @@ const HelpSystem = (function () {
'Il campo <code>external_ref</code> abilita la deduplica: re-importare lo stesso ID aggiorna il bene invece di duplicarlo.',
'Gli stessi dati possono arrivare via connettore automatico (API <code>POST /api/services/assets-ingest</code>, scope ingest:assets).'
]
},
{
heading: 'Esporta (pulsante "Esporta")',
items: [
'Scarica l\'intero inventario in un file <strong>CSV</strong> con le <strong>stesse colonne dell\'importazione</strong>, così puoi salvarne una copia, modificarlo fuori e re-importarlo senza riformattare nulla.',
'Il file usa le colonne riconosciute dall\'import; re-importandolo lo <strong>scoring di rilevanza NIS2</strong> viene ricalcolato e i beni con lo stesso <code>external_ref</code> vengono aggiornati (nessun duplicato).',
'Utile per backup, migrazioni e modifiche massive in foglio di calcolo (il file è in UTF-8, apribile direttamente in Excel).'
]
}
],
references: [
+1 -1
View File
@@ -163,7 +163,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 src="/js/kb.js"></script>
<script>
// Gate auth + chrome come le altre pagine -bi.
+1 -1
View File
@@ -131,7 +131,7 @@
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
'use strict';
/*
+1 -1
View File
@@ -172,7 +172,7 @@
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
'use strict';
/*
+1 -1
View File
@@ -125,7 +125,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>
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
+1 -1
View File
@@ -143,7 +143,7 @@
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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 src="/js/organigramma.js?v=20260617"></script>
</body>
</html>
+1 -1
View File
@@ -346,7 +346,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');
+1 -1
View File
@@ -136,7 +136,7 @@
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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 src="/js/raci.js?v=20260619"></script>
</body>
</html>
+1 -1
View File
@@ -456,7 +456,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');
+1 -1
View File
@@ -146,7 +146,7 @@
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
'use strict';
/*
+1 -1
View File
@@ -507,7 +507,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');
+1 -1
View File
@@ -701,7 +701,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 check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
+1 -1
View File
@@ -199,7 +199,7 @@
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
'use strict';
/*
+1 -1
View File
@@ -196,7 +196,7 @@
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
'use strict';
/*
+1 -1
View File
@@ -490,7 +490,7 @@
</script>
<script src="/js/common-bi.js?v=20260624f"></script>
<script src="/js/i18n.js?v=20260621sp"></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');
+1 -1
View File
@@ -305,7 +305,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');
+1 -1
View File
@@ -1 +1 @@
{"version": "1.24.13", "build": "2026-06-25-v1.24.13", "date": "2026-06-25", "changelog": "Inventario beni — Aggiunta la Mappa delle Dipendenze: nuovo pulsante 'Mappa Dipendenze' che mostra le relazioni tra i beni, e nel form di ogni bene un campo per selezionare i beni da cui dipende (supporta l'analisi del rischio, art. 24 D.Lgs. 138/2024). Aggiunto il pulsante per eliminare un bene dall'inventario (lista e dettaglio). Risolti ticket #440 e #439."}
{"version": "1.24.14", "build": "2026-06-25-v1.24.14", "date": "2026-06-25", "changelog": "Inventario beni — Aggiunto il pulsante 'Esporta': scarica l'intero inventario in CSV con le stesse colonne dell'importazione, cosi puoi salvarne una copia, modificarlo in foglio di calcolo e re-importarlo senza riformattare nulla (re-import: scoring NIS2 ricalcolato, deduplica per external_ref). Il parser di importazione ora gestisce le virgolette CSV, cosi nomi con virgola si re-importano correttamente. Risolve ticket #441."}
+1 -1
View File
@@ -220,7 +220,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>
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
+1 -1
View File
@@ -326,7 +326,7 @@
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();