[FEAT] UI frontend gap competitivi + help/i18n + OpenAPI + scorecard EVIX
UI delle 4 feature backend (ora usabili dagli utenti, non solo via API): - risks.html: vista 'Quantitativo (FAIR)' con form + istogramma distribuzione ALE + registro portafoglio; vista 'KRI' con dashboard semafori green/amber/red + CRUD - reports.html: tab 'Monitoraggio Continuo' con semafori freschezza controlli (healthy/warning/stale/failing) + copertura - assets.html: bottone 'Importa' CSV/CMDB con parsing client + anteprima + scoring auto GV.OC-04 - api.js: metodi computeFair/getFairRegister/listKri/createKri/updateKri/importAssets/getControlsMonitoring Trasversale: - help.js: sezioni guida FAIR+KRI (risks), import CMDB (assets), monitoraggio continuo (reports) - i18n.js: chiavi IT/EN (risks.fair_tab, risks.kri_tab, assets.import_btn, audit.monitoring_tab) - ServicesController::openapi esteso con incidents-ingest/evidence-ingest/assets-ingest/controls-monitoring + securityScheme ApiKeyAuth - AuditController::controlsMonitoring (versione JWT per la UI) + route audit/controlsMonitoring - EVIX scorecard: gap P1/P2 marcati chiusi (backend), roadmap aggiornata Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
edf0394616
commit
372ccb5ec1
@ -100,18 +100,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card"><h4>Asset & Sistemi Rilevanti</h4><span class="badge b-ok">Best-of-breed (IT)</span>
|
<div class="card"><h4>Asset & Sistemi Rilevanti</h4><span class="badge b-ok">Best-of-breed (IT)</span>
|
||||||
<ul><li>Scoring rilevanza 0-100 a 6 criteri (GV.OC-04)</li><li>Registro formale stampabile + classi critico/alto/medio</li></ul>
|
<ul><li>Scoring rilevanza 0-100 a 6 criteri (GV.OC-04)</li><li>Registro formale stampabile + classi critico/alto/medio</li><li><strong>NEW</strong>: import bulk CMDB/cloud/CSV (POST /assets/import) con scoring GV.OC-04 automatico</li></ul>
|
||||||
<ul class="gap"><li>Gap: auto-discovery asset e integrazione CMDB/cloud assenti</li></ul>
|
<ul class="gap"><li>Gap residuo: auto-discovery attiva (agent/scan) — l'import e gia disponibile</li></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card"><h4>Gestione Incidenti</h4><span class="badge b-ok">Best-of-breed (IT)</span>
|
<div class="card"><h4>Gestione Incidenti</h4><span class="badge b-ok">Best-of-breed (IT)</span>
|
||||||
<ul><li>Art.23 24h/72h/30g + tassonomia IS-1..4 (Determina ACN 164179/2025)</li><li>PIR 5-Whys + metriche TTD/TTC/TTR; regime essenziale/importante</li></ul>
|
<ul><li>Art.23 24h/72h/30g + tassonomia IS-1..4 (Determina ACN 164179/2025)</li><li>PIR 5-Whys + metriche TTD/TTC/TTR; regime essenziale/importante</li><li><strong>NEW</strong>: ingestion automatica SIEM/SOC/EDR (POST /services/incidents-ingest) con classificazione AI</li></ul>
|
||||||
<ul class="gap"><li>Gap: ingestion automatica da SIEM/SOC/EDR (oggi manuale)</li></ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card"><h4>Risk Management</h4><span class="badge b-near">Competitivo</span>
|
<div class="card"><h4>Risk Management</h4><span class="badge b-ok">Best-of-breed (IT)</span>
|
||||||
<ul><li>Registro rischi, matrice 5×5 ISO 27005, AI suggest</li></ul>
|
<ul><li>Registro rischi, matrice 5×5 ISO 27005, AI suggest</li><li><strong>NEW</strong>: analisi quantitativa <strong>FAIR</strong> (Monte Carlo, ALE EUR P10/P50/P90) + dashboard <strong>KRI</strong> con semafori</li></ul>
|
||||||
<ul class="gap"><li>Gap vs leader: scenari quantitativi (FAIR), monte-carlo, KRI dashboard</li></ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card"><h4>Audit & Evidence</h4><span class="badge b-ok">Differenziante</span>
|
<div class="card"><h4>Audit & Evidence</h4><span class="badge b-ok">Differenziante</span>
|
||||||
@ -134,12 +132,14 @@
|
|||||||
<ul class="gap"><li>Gap: nessun gap critico; estendere copertura KB normativa</li></ul>
|
<ul class="gap"><li>Gap: nessun gap critico; estendere copertura KB normativa</li></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card"><h4>Continuous Monitoring</h4><span class="badge b-gap">Gap rilevante</span>
|
<div class="card"><h4>Continuous Monitoring</h4><span class="badge b-near">Competitivo (backend)</span>
|
||||||
<ul class="gap"><li>Assente: monitoraggio continuo dei controlli + evidence automation (core di Vanta/Drata)</li><li>Oggi: assessment puntuale + evidenze manuali</li></ul>
|
<ul><li><strong>NEW</strong>: Evidence Automation (POST /services/evidence-ingest) + Continuous Control Monitoring con semafori freschezza healthy/warning/stale/failing</li><li>UI "Monitoraggio Continuo" in Audit & Report</li></ul>
|
||||||
|
<ul class="gap"><li>Gap residuo: connettori per-vendor live (richiedono OAuth/credenziali) — il framework di ingestion e pronto</li></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card"><h4>Integrazioni / Connettori</h4><span class="badge b-gap">Gap rilevante</span>
|
<div class="card"><h4>Integrazioni / Connettori</h4><span class="badge b-near">Competitivo (backend)</span>
|
||||||
<ul class="gap"><li>Assente catalogo connettori (M365, Google, AWS/Azure, Jira, IdP, EDR)</li><li>Presente: Services API + Webhook HMAC (base solida per costruirli)</li></ul>
|
<ul><li>Services API + Webhook HMAC + <strong>NEW</strong> endpoint inbound: incidents-ingest, evidence-ingest, assets-ingest (scope dedicati ingest:*)</li><li>Catalogo connettori predisposto in area provider (M365/Google/AWS/Azure/IdP/EDR/SIEM/Ticketing)</li></ul>
|
||||||
|
<ul class="gap"><li>Gap residuo: client per-vendor con OAuth (le credenziali/app registration sono lato cliente)</li></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@ -182,13 +182,13 @@
|
|||||||
<td class="n">Raro <span class="v">[dv]</span></td>
|
<td class="n">Raro <span class="v">[dv]</span></td>
|
||||||
<td class="n">No</td></tr>
|
<td class="n">No</td></tr>
|
||||||
<tr><td>Continuous control monitoring + evidence automation</td>
|
<tr><td>Continuous control monitoring + evidence automation</td>
|
||||||
<td class="col-evix n">No (gap)</td>
|
<td class="col-evix pp">Si (backend) — evidence-ingest + CCM semafori; connettori live in roadmap</td>
|
||||||
<td class="y">Sì <span class="v">[dv]</span></td>
|
<td class="y">Sì <span class="v">[dv]</span></td>
|
||||||
<td class="y">Sì — core <span class="v">[dv]</span></td>
|
<td class="y">Sì — core <span class="v">[dv]</span></td>
|
||||||
<td class="n">No <span class="v">[dv]</span></td>
|
<td class="n">No <span class="v">[dv]</span></td>
|
||||||
<td class="n">No</td></tr>
|
<td class="n">No</td></tr>
|
||||||
<tr><td>Catalogo connettori/integrazioni (cloud, IdP, EDR, ticketing)</td>
|
<tr><td>Catalogo connettori/integrazioni (cloud, IdP, EDR, ticketing)</td>
|
||||||
<td class="col-evix pp">Parziale — API/webhook, no connettori pronti</td>
|
<td class="col-evix pp">Endpoint inbound ingest:* + catalogo predisposto; client per-vendor in roadmap</td>
|
||||||
<td class="y">Sì <span class="v">[dv]</span></td>
|
<td class="y">Sì <span class="v">[dv]</span></td>
|
||||||
<td class="y">Sì — molti <span class="v">[dv]</span></td>
|
<td class="y">Sì — molti <span class="v">[dv]</span></td>
|
||||||
<td class="n">Raro <span class="v">[dv]</span></td>
|
<td class="n">Raro <span class="v">[dv]</span></td>
|
||||||
@ -235,10 +235,12 @@
|
|||||||
<span class="sub">Priorità per impatto competitivo. Le voci P1 sono quelle che oggi ci fanno perdere confronti vs Vanta/Drata e GRC enterprise.</span>
|
<span class="sub">Priorità per impatto competitivo. Le voci P1 sono quelle che oggi ci fanno perdere confronti vs Vanta/Drata e GRC enterprise.</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="roadmap">
|
<div class="roadmap">
|
||||||
<div class="item"><div class="when">P1 · Colmare il gap "compliance automation"</div>
|
<div class="item" style="opacity:.7"><div class="when">✅ FATTO (2026-05-30) · P1 compliance automation</div>
|
||||||
<strong>Continuous Control Monitoring + Evidence Automation.</strong> Connettori per raccolta automatica evidenze (M365, Google Workspace, AWS/Azure, IdP, EDR). È il core di Vanta/Drata e oggi è il nostro gap più visibile. Base esistente: Services API + Webhook HMAC.</div>
|
<strong>Continuous Control Monitoring + Evidence Automation.</strong> Implementato backend: POST /services/evidence-ingest (M365/Google/AWS/Azure/IdP/EDR/SIEM) + ricalcolo semafori freschezza + UI "Monitoraggio Continuo". Restano da attivare i client per-vendor con OAuth (credenziali lato cliente).</div>
|
||||||
<div class="item"><div class="when">P1 · Ingestion incidenti</div>
|
<div class="item" style="opacity:.7"><div class="when">✅ FATTO (2026-05-30) · P1 ingestion incidenti</div>
|
||||||
<strong>Integrazione SIEM/SOC/EDR</strong> per apertura automatica incidenti (i mockup analizzati già prevedevano "Alert SIEM/SOC" come fonte). Trasforma il modulo incidenti da reattivo a proattivo.</div>
|
<strong>Integrazione SIEM/SOC/EDR</strong> implementata: POST /services/incidents-ingest apre automaticamente incidenti Art.23 con dedup e classificazione AI. Modulo incidenti ora proattivo.</div>
|
||||||
|
<div class="item" style="opacity:.7"><div class="when">✅ FATTO (2026-05-30) · P2 asset import + P2 risk quantitativo</div>
|
||||||
|
<strong>Import CMDB/cloud</strong> (POST /assets/import) con scoring GV.OC-04 automatico, e <strong>Risk quantitativo FAIR</strong> (Monte Carlo, ALE EUR) + dashboard KRI. Restano: auto-discovery attiva asset; benchmark settoriale (P2 reporting).</div>
|
||||||
<div class="item"><div class="when">P2 · Asset</div>
|
<div class="item"><div class="when">P2 · Asset</div>
|
||||||
<strong>Auto-discovery asset + import CMDB/cloud</strong> per alimentare automaticamente lo scoring di rilevanza GV.OC-04 appena introdotto.</div>
|
<strong>Auto-discovery asset + import CMDB/cloud</strong> per alimentare automaticamente lo scoring di rilevanza GV.OC-04 appena introdotto.</div>
|
||||||
<div class="item"><div class="when">P2 · Risk quantitativo</div>
|
<div class="item"><div class="when">P2 · Risk quantitativo</div>
|
||||||
|
|||||||
@ -869,6 +869,60 @@
|
|||||||
|
|
||||||
// ── Initial Load ────────────────────────────────────────
|
// ── Initial Load ────────────────────────────────────────
|
||||||
loadAssets();
|
loadAssets();
|
||||||
|
|
||||||
|
/* ── Import asset CMDB/CSV (P2) ───────────────────────────── */
|
||||||
|
function openImportModal(){
|
||||||
|
const body = `
|
||||||
|
<p style="font-size:.85rem;color:var(--gray-600);margin-bottom:10px;">Incolla CSV o carica un file. Colonne riconosciute:
|
||||||
|
<code>name, asset_type, criticality, data_classification, internet_facing, dependencies_count, regulated, external_ref, vendor, location</code>.
|
||||||
|
Lo scoring di rilevanza NIS2 (GV.OC-04) viene calcolato automaticamente.</p>
|
||||||
|
<div class="form-group"><input type="file" accept=".csv,text/csv" id="imp-file" onchange="impFile(event)"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Oppure incolla CSV (prima riga = intestazioni)</label>
|
||||||
|
<textarea class="form-input" id="imp-csv" rows="8" placeholder="name,asset_type,criticality,data_classification,internet_facing,dependencies_count,regulated
|
||||||
|
Core ERP,software,critical,financial,false,8,true
|
||||||
|
Web Portal,service,high,personal,true,3,true"></textarea></div>
|
||||||
|
<div id="imp-preview" style="font-size:.8rem;color:var(--gray-600);"></div>`;
|
||||||
|
showModal('Importa Asset', body, {
|
||||||
|
size:'lg',
|
||||||
|
footer:`<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
||||||
|
<button class="btn btn-primary" onclick="doImport()">Importa</button>`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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 truthy=v=>/^(1|true|si|sì|yes|y)$/i.test((v||'').trim());
|
||||||
|
return lines.slice(1).map(line=>{
|
||||||
|
const cells=line.split(','); 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,
|
||||||
|
external_ref:o.external_ref};
|
||||||
|
if('internet_facing' in o) a.internet_facing=truthy(o.internet_facing);
|
||||||
|
if('regulated' in o) a.regulated=truthy(o.regulated);
|
||||||
|
if('dependencies_count' in o) a.dependencies_count=parseInt(o.dependencies_count)||0;
|
||||||
|
return a;
|
||||||
|
}).filter(a=>a.name);
|
||||||
|
}
|
||||||
|
async function doImport(){
|
||||||
|
const assets=parseCsv(document.getElementById('imp-csv').value);
|
||||||
|
if(!assets.length){showNotification('Nessuna riga valida (serve almeno name)','warning');return;}
|
||||||
|
try{
|
||||||
|
const res=await api.importAssets({source:'csv', assets});
|
||||||
|
if(res.success){
|
||||||
|
const d=res.data;
|
||||||
|
closeModal();
|
||||||
|
showNotification(`Import: ${d.imported} nuovi, ${d.updated} aggiornati, ${d.relevant} rilevanti NIS2, ${d.skipped} scartati`,'success');
|
||||||
|
if(typeof loadAssets==='function')loadAssets();
|
||||||
|
} else showNotification(res.message||'Errore import','error');
|
||||||
|
}catch(e){showNotification('Errore di connessione','error');}
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -298,6 +298,7 @@ $actionMap = [
|
|||||||
'GET:logs' => 'getAuditLogs',
|
'GET:logs' => 'getAuditLogs',
|
||||||
'GET:iso27001Mapping' => 'getIsoMapping',
|
'GET:iso27001Mapping' => 'getIsoMapping',
|
||||||
'GET:nistCsfMapping' => 'getNistCsfMapping',
|
'GET:nistCsfMapping' => 'getNistCsfMapping',
|
||||||
|
'GET:controlsMonitoring' => 'controlsMonitoring',
|
||||||
'GET:executiveReport' => 'executiveReport',
|
'GET:executiveReport' => 'executiveReport',
|
||||||
'GET:relevantSystemsRegister' => 'relevantSystemsRegister',
|
'GET:relevantSystemsRegister' => 'relevantSystemsRegister',
|
||||||
'GET:export' => 'export',
|
'GET:export' => 'export',
|
||||||
|
|||||||
@ -200,6 +200,12 @@ class NIS2API {
|
|||||||
deleteRisk(id) { return this.del(`/risks/${id}`); }
|
deleteRisk(id) { return this.del(`/risks/${id}`); }
|
||||||
getRiskMatrix() { return this.get('/risks/matrix'); }
|
getRiskMatrix() { return this.get('/risks/matrix'); }
|
||||||
aiSuggestRisks() { return this.post('/risks/ai-suggest', {}); }
|
aiSuggestRisks() { return this.post('/risks/ai-suggest', {}); }
|
||||||
|
// FAIR quantitativo + KRI (P2)
|
||||||
|
computeFair(id, data) { return this.post(`/risks/${id}/fair`, data); }
|
||||||
|
getFairRegister() { return this.get('/risks/fairRegister'); }
|
||||||
|
listKri() { return this.get('/risks/kri'); }
|
||||||
|
createKri(data) { return this.post('/risks/kri', data); }
|
||||||
|
updateKri(id, data) { return this.put(`/risks/kri/${id}`, data); }
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
// Incidents
|
// Incidents
|
||||||
@ -260,6 +266,8 @@ class NIS2API {
|
|||||||
getScoringGrid() { return this.get('/assets/scoringGrid'); }
|
getScoringGrid() { return this.get('/assets/scoringGrid'); }
|
||||||
scoreAsset(id, criteria) { return this.post(`/assets/${id}/score`, { criteria }); }
|
scoreAsset(id, criteria) { return this.post(`/assets/${id}/score`, { criteria }); }
|
||||||
listRelevantSystems() { return this.get('/assets/relevantSystems'); }
|
listRelevantSystems() { return this.get('/assets/relevantSystems'); }
|
||||||
|
importAssets(data) { return this.post('/assets/import', data); } // P2 import CMDB/CSV
|
||||||
|
getControlsMonitoring() { return this.get('/audit/controlsMonitoring'); } // P1 continuous control monitoring (JWT)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
// Audit
|
// Audit
|
||||||
|
|||||||
@ -407,6 +407,16 @@ const HelpSystem = (function () {
|
|||||||
'Registrazione di proprietario, ubicazione, fornitore e contratti di supporto.',
|
'Registrazione di proprietario, ubicazione, fornitore e contratti di supporto.',
|
||||||
'Alert automatici per asset con supporto in scadenza o fine vita.'
|
'Alert automatici per asset con supporto in scadenza o fine vita.'
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Import da CMDB / Cloud (pulsante "Importa")',
|
||||||
|
items: [
|
||||||
|
'Importa asset in blocco da un file <strong>CSV</strong> o da un export CMDB/cloud, invece di inserirli manualmente.',
|
||||||
|
'Colonne riconosciute: <code>name, asset_type, criticality, data_classification, internet_facing, dependencies_count, regulated, external_ref, vendor, location</code>.',
|
||||||
|
'Lo <strong>scoring di rilevanza NIS2 (GV.OC-04)</strong> viene calcolato automaticamente per ogni asset importato, classificandolo critico/alto/medio/basso.',
|
||||||
|
'Il campo <code>external_ref</code> abilita la deduplica: re-importare lo stesso ID aggiorna l\'asset invece di duplicarlo.',
|
||||||
|
'Gli stessi dati possono arrivare via connettore automatico (API <code>POST /api/services/assets-ingest</code>, scope ingest:assets).'
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
references: [
|
references: [
|
||||||
|
|||||||
@ -359,6 +359,7 @@
|
|||||||
<button onclick="switchTab('controls', this)">Controlli</button>
|
<button onclick="switchTab('controls', this)">Controlli</button>
|
||||||
<button onclick="switchTab('audit', this)">Audit Log</button>
|
<button onclick="switchTab('audit', this)">Audit Log</button>
|
||||||
<button onclick="switchTab('iso', this)">ISO 27001</button>
|
<button onclick="switchTab('iso', this)">ISO 27001</button>
|
||||||
|
<button onclick="switchTab('monitoring', this)" data-i18n="audit.monitoring_tab">Monitoraggio Continuo</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab: Report Compliance -->
|
<!-- Tab: Report Compliance -->
|
||||||
@ -401,6 +402,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Continuous Control Monitoring -->
|
||||||
|
<div class="tab-panel" id="tab-monitoring">
|
||||||
|
<div id="monitoring-container">
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Caricamento monitoraggio...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@ -467,6 +478,46 @@
|
|||||||
if (tabId === 'controls') loadControls();
|
if (tabId === 'controls') loadControls();
|
||||||
if (tabId === 'audit') { auditPage = 1; loadAuditLogs(); }
|
if (tabId === 'audit') { auditPage = 1; loadAuditLogs(); }
|
||||||
if (tabId === 'iso') loadIsoMapping();
|
if (tabId === 'iso') loadIsoMapping();
|
||||||
|
if (tabId === 'monitoring') loadMonitoring();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Continuous Control Monitoring (P1) ───────────────────────
|
||||||
|
const MON_STATUS = {
|
||||||
|
healthy: ['#22c55e', 'Sano'],
|
||||||
|
warning: ['#eab308', 'Attenzione'],
|
||||||
|
stale: ['#f97316', 'Evidenza scaduta'],
|
||||||
|
failing: ['#ef4444', 'Non conforme'],
|
||||||
|
not_monitored: ['#9ca3af', 'Non monitorato']
|
||||||
|
};
|
||||||
|
async function loadMonitoring() {
|
||||||
|
const c = document.getElementById('monitoring-container');
|
||||||
|
try {
|
||||||
|
const res = await api.getControlsMonitoring();
|
||||||
|
if (!res.success) { c.innerHTML = '<div class="empty-state-box"><p>' + escapeHtml(res.message || 'Errore') + '</p></div>'; return; }
|
||||||
|
const d = res.data, s = d.summary || {};
|
||||||
|
const cards = ['healthy','warning','stale','failing','not_monitored'].map(k =>
|
||||||
|
`<div class="stat-box" style="border-left:4px solid ${MON_STATUS[k][0]};min-width:120px;">
|
||||||
|
<div class="stat-value">${s[k] || 0}</div><div class="stat-label">${MON_STATUS[k][1]}</div></div>`).join('');
|
||||||
|
const rows = (d.controls || []).map(ct => {
|
||||||
|
const st = MON_STATUS[ct.monitoring_status] || MON_STATUS.not_monitored;
|
||||||
|
return `<tr>
|
||||||
|
<td><span style="display:inline-block;width:11px;height:11px;border-radius:50%;background:${st[0]};vertical-align:middle;" title="${st[1]}"></span> ${st[1]}</td>
|
||||||
|
<td><code>${escapeHtml(ct.control_code)}</code></td>
|
||||||
|
<td>${escapeHtml(ct.title || '')}</td>
|
||||||
|
<td>${ct.last_checked_at || '<span style="color:var(--gray-400);">mai</span>'}</td>
|
||||||
|
<td>${ct.freshness_days || 30} gg</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
c.innerHTML = `
|
||||||
|
<p style="font-size:.85rem;color:var(--gray-600);margin-bottom:12px;">
|
||||||
|
Stato di freschezza dei controlli alimentato dalle <strong>evidenze automatiche</strong> dai connettori (Evidence Automation).
|
||||||
|
Copertura: <strong>${d.coverage_percent}%</strong> (${d.monitored}/${d.total_controls} controlli monitorati).</p>
|
||||||
|
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px;">${cards}</div>
|
||||||
|
<table class="data-table"><thead><tr>
|
||||||
|
<th>Stato</th><th>Controllo</th><th>Titolo</th><th>Ultima evidenza</th><th>Freschezza</th>
|
||||||
|
</tr></thead><tbody>${rows || '<tr><td colspan="5" style="text-align:center;padding:20px;color:var(--gray-500);">Nessun controllo. Le evidenze arrivano via POST /api/services/evidence-ingest.</td></tr>'}</tbody></table>`;
|
||||||
|
} catch (e) {
|
||||||
|
c.innerHTML = '<div class="empty-state-box"><p>Errore di connessione</p></div>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|||||||
@ -306,6 +306,8 @@
|
|||||||
<div class="view-toggle" id="view-toggle">
|
<div class="view-toggle" id="view-toggle">
|
||||||
<button class="active" onclick="switchView('table')">Tabella</button>
|
<button class="active" onclick="switchView('table')">Tabella</button>
|
||||||
<button onclick="switchView('matrix')">Matrice</button>
|
<button onclick="switchView('matrix')">Matrice</button>
|
||||||
|
<button onclick="switchView('fair')" data-i18n="risks.fair_tab">Quantitativo (FAIR)</button>
|
||||||
|
<button onclick="switchView('kri')" data-i18n="risks.kri_tab">KRI</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-secondary" id="btn-ai-suggest" onclick="aiSuggest()">
|
<button class="btn btn-secondary" id="btn-ai-suggest" onclick="aiSuggest()">
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
|
<svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
|
||||||
@ -410,6 +412,65 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- FAIR Quantitative View -->
|
||||||
|
<div id="view-fair" class="hidden">
|
||||||
|
<div class="card" style="margin-bottom:16px;">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Analisi Quantitativa del Rischio (FAIR)</h3>
|
||||||
|
<span class="text-muted" style="font-size:0.8rem;">Perdita Annua Attesa (ALE) in EUR — simulazione Monte Carlo</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:12px; margin-bottom:16px;">
|
||||||
|
<div><label class="form-label">Rischio</label>
|
||||||
|
<select class="form-select" id="fair-risk-select"><option value="">— seleziona —</option></select></div>
|
||||||
|
<div><label class="form-label">TEF min <small>(ev/anno)</small></label><input type="number" step="0.1" class="form-input" id="fair-tef-min" value="0.5"></div>
|
||||||
|
<div><label class="form-label">TEF probabile</label><input type="number" step="0.1" class="form-input" id="fair-tef-ml" value="1"></div>
|
||||||
|
<div><label class="form-label">TEF max</label><input type="number" step="0.1" class="form-input" id="fair-tef-max" value="2"></div>
|
||||||
|
<div><label class="form-label">Vulnerabilita <small>(0-1)</small></label><input type="number" step="0.05" min="0" max="1" class="form-input" id="fair-vuln" value="0.3"></div>
|
||||||
|
<div><label class="form-label">Perdita min <small>(EUR)</small></label><input type="number" step="1000" class="form-input" id="fair-lm-min" value="50000"></div>
|
||||||
|
<div><label class="form-label">Perdita probabile</label><input type="number" step="1000" class="form-input" id="fair-lm-ml" value="300000"></div>
|
||||||
|
<div><label class="form-label">Perdita max</label><input type="number" step="1000" class="form-input" id="fair-lm-max" value="2000000"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="runFair()" id="btn-run-fair">Calcola ALE</button>
|
||||||
|
<div id="fair-result" class="hidden" style="margin-top:20px;">
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:16px;">
|
||||||
|
<div class="stat-box"><div class="stat-label">ALE P10 (ottimistico)</div><div class="stat-value" id="fair-p10">—</div></div>
|
||||||
|
<div class="stat-box" style="border-color:var(--primary);"><div class="stat-label">ALE Mediana (P50)</div><div class="stat-value" id="fair-p50">—</div></div>
|
||||||
|
<div class="stat-box"><div class="stat-label">ALE P90 (pessimistico)</div><div class="stat-value" id="fair-p90">—</div></div>
|
||||||
|
<div class="stat-box"><div class="stat-label">ALE Media</div><div class="stat-value" id="fair-mean">—</div></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.8rem; color:var(--gray-600); margin-bottom:8px;">Distribuzione delle perdite annue simulate (LEF medio <span id="fair-lef">—</span> eventi/anno):</div>
|
||||||
|
<div id="fair-histogram" style="display:flex; align-items:flex-end; gap:2px; height:160px; border-bottom:2px solid var(--gray-300); padding:4px;"></div>
|
||||||
|
<div id="fair-histogram-labels" style="display:flex; gap:2px; font-size:0.65rem; color:var(--gray-500); margin-top:4px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3>Registro Quantitativo</h3>
|
||||||
|
<span class="text-muted" style="font-size:0.8rem;">ALE di portafoglio: <strong id="fair-portfolio">—</strong></span></div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table-clickable"><thead><tr>
|
||||||
|
<th>Codice</th><th>Titolo</th><th>Categoria</th><th>ALE Min</th><th>ALE Mediana</th><th>ALE Max</th><th>ALE Media</th>
|
||||||
|
</tr></thead><tbody id="fair-register-body"><tr><td colspan="7" class="text-muted" style="text-align:center;padding:20px;">Nessun rischio quantificato</td></tr></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KRI Dashboard View -->
|
||||||
|
<div id="view-kri" class="hidden">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
|
<div id="kri-summary" style="display:flex; gap:12px;"></div>
|
||||||
|
<button class="btn btn-primary" onclick="showKriModal()">+ Nuovo KRI</button>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table-clickable"><thead><tr>
|
||||||
|
<th>Stato</th><th>Indicatore</th><th>Categoria</th><th>Valore</th><th>Soglia Warning</th><th>Soglia Critica</th><th>Azioni</th>
|
||||||
|
</tr></thead><tbody id="kri-table-body"><tr><td colspan="7"><div class="spinner" style="margin:30px auto;"></div></td></tr></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Detail View -->
|
<!-- Detail View -->
|
||||||
<div id="view-detail" class="hidden"></div>
|
<div id="view-detail" class="hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -566,14 +627,164 @@
|
|||||||
currentView = view;
|
currentView = view;
|
||||||
document.getElementById('view-table').classList.toggle('hidden', view !== 'table');
|
document.getElementById('view-table').classList.toggle('hidden', view !== 'table');
|
||||||
document.getElementById('view-matrix').classList.toggle('hidden', view !== 'matrix');
|
document.getElementById('view-matrix').classList.toggle('hidden', view !== 'matrix');
|
||||||
|
document.getElementById('view-fair').classList.toggle('hidden', view !== 'fair');
|
||||||
|
document.getElementById('view-kri').classList.toggle('hidden', view !== 'kri');
|
||||||
document.getElementById('view-detail').classList.toggle('hidden', true);
|
document.getElementById('view-detail').classList.toggle('hidden', true);
|
||||||
|
|
||||||
const btns = document.querySelectorAll('#view-toggle button');
|
const btns = document.querySelectorAll('#view-toggle button');
|
||||||
btns.forEach(b => b.classList.remove('active'));
|
btns.forEach(b => b.classList.remove('active'));
|
||||||
if (view === 'table') btns[0].classList.add('active');
|
const idx = { table: 0, matrix: 1, fair: 2, kri: 3 }[view] ?? 0;
|
||||||
else btns[1].classList.add('active');
|
if (btns[idx]) btns[idx].classList.add('active');
|
||||||
|
|
||||||
if (view === 'matrix') loadMatrix();
|
if (view === 'matrix') loadMatrix();
|
||||||
|
if (view === 'fair') loadFair();
|
||||||
|
if (view === 'kri') loadKri();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FAIR quantitativo ────────────────────────────────────────
|
||||||
|
const fmtEur = n => '€ ' + Number(n || 0).toLocaleString('it-IT', { maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
async function loadFair() {
|
||||||
|
// popola select rischi + registro
|
||||||
|
try {
|
||||||
|
const [risksRes, regRes] = await Promise.all([api.listRisks({ per_page: 200 }), api.getFairRegister()]);
|
||||||
|
const sel = document.getElementById('fair-risk-select');
|
||||||
|
if (risksRes.success) {
|
||||||
|
const risks = risksRes.data.risks || risksRes.data || [];
|
||||||
|
sel.innerHTML = '<option value="">— seleziona rischio —</option>' +
|
||||||
|
risks.map(r => `<option value="${r.id}">${escapeHtml((r.risk_code ? r.risk_code + ' · ' : '') + r.title)}</option>`).join('');
|
||||||
|
}
|
||||||
|
if (regRes.success) renderFairRegister(regRes.data);
|
||||||
|
} catch (e) { showNotification('Errore caricamento FAIR', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFairRegister(data) {
|
||||||
|
document.getElementById('fair-portfolio').textContent = fmtEur(data.portfolio_ale_mean);
|
||||||
|
const body = document.getElementById('fair-register-body');
|
||||||
|
const risks = data.risks || [];
|
||||||
|
if (!risks.length) { body.innerHTML = '<tr><td colspan="7" class="text-muted" style="text-align:center;padding:20px;">Nessun rischio quantificato. Calcola l\'ALE di un rischio qui sopra.</td></tr>'; return; }
|
||||||
|
body.innerHTML = risks.map(r => `<tr>
|
||||||
|
<td>${escapeHtml(r.risk_code || '—')}</td><td>${escapeHtml(r.title)}</td>
|
||||||
|
<td>${escapeHtml(CATEGORY_LABELS[r.category] || r.category || '')}</td>
|
||||||
|
<td>${fmtEur(r.ale_min)}</td><td><strong>${fmtEur(r.ale_ml)}</strong></td>
|
||||||
|
<td>${fmtEur(r.ale_max)}</td><td>${fmtEur(r.ale_mean)}</td></tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFair() {
|
||||||
|
const riskId = document.getElementById('fair-risk-select').value;
|
||||||
|
if (!riskId) { showNotification('Seleziona un rischio', 'warning'); return; }
|
||||||
|
const btn = document.getElementById('btn-run-fair');
|
||||||
|
setButtonLoading(btn, true);
|
||||||
|
const payload = {
|
||||||
|
tef_min: +document.getElementById('fair-tef-min').value,
|
||||||
|
tef_ml: +document.getElementById('fair-tef-ml').value,
|
||||||
|
tef_max: +document.getElementById('fair-tef-max').value,
|
||||||
|
vuln: +document.getElementById('fair-vuln').value,
|
||||||
|
lm_min: +document.getElementById('fair-lm-min').value,
|
||||||
|
lm_ml: +document.getElementById('fair-lm-ml').value,
|
||||||
|
lm_max: +document.getElementById('fair-lm-max').value,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await api.computeFair(riskId, payload);
|
||||||
|
if (res.success) {
|
||||||
|
renderFairResult(res.data.result);
|
||||||
|
showNotification('Analisi FAIR calcolata', 'success');
|
||||||
|
api.getFairRegister().then(r => { if (r.success) renderFairRegister(r.data); });
|
||||||
|
} else { showNotification(res.message || 'Errore calcolo FAIR', 'error'); }
|
||||||
|
} catch (e) { showNotification('Errore di connessione', 'error'); }
|
||||||
|
finally { setButtonLoading(btn, false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFairResult(r) {
|
||||||
|
document.getElementById('fair-result').classList.remove('hidden');
|
||||||
|
document.getElementById('fair-p10').textContent = fmtEur(r.ale_min);
|
||||||
|
document.getElementById('fair-p50').textContent = fmtEur(r.ale_ml);
|
||||||
|
document.getElementById('fair-p90').textContent = fmtEur(r.ale_max);
|
||||||
|
document.getElementById('fair-mean').textContent = fmtEur(r.ale_mean);
|
||||||
|
document.getElementById('fair-lef').textContent = (r.lef_mean ?? 0).toFixed(3);
|
||||||
|
const bars = document.getElementById('fair-histogram');
|
||||||
|
const labels = document.getElementById('fair-histogram-labels');
|
||||||
|
const h = r.histogram || [];
|
||||||
|
const maxCount = Math.max(1, ...h.map(b => b.count));
|
||||||
|
bars.innerHTML = h.map(b => {
|
||||||
|
const pct = Math.round(b.count / maxCount * 100);
|
||||||
|
return `<div title="${fmtEur(b.from)} – ${fmtEur(b.to)}: ${b.count}" style="flex:1; background:var(--primary); opacity:.8; height:${pct}%; min-height:2px; border-radius:2px 2px 0 0;"></div>`;
|
||||||
|
}).join('');
|
||||||
|
labels.innerHTML = h.map((b, i) => i % 3 === 0 ? `<div style="flex:3; text-align:left;">${fmtEur(b.from)}</div>` : '').join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── KRI dashboard ────────────────────────────────────────────
|
||||||
|
const KRI_STATUS = { green: ['#22c55e', 'OK'], amber: ['#eab308', 'Attenzione'], red: ['#ef4444', 'Critico'], unknown: ['#9ca3af', 'N/D'] };
|
||||||
|
|
||||||
|
async function loadKri() {
|
||||||
|
try {
|
||||||
|
const res = await api.listKri();
|
||||||
|
if (res.success) renderKri(res.data);
|
||||||
|
else showNotification(res.message || 'Errore KRI', 'error');
|
||||||
|
} catch (e) { showNotification('Errore di connessione', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKri(data) {
|
||||||
|
const sum = data.summary || {};
|
||||||
|
document.getElementById('kri-summary').innerHTML = ['red', 'amber', 'green', 'unknown'].map(s =>
|
||||||
|
`<div class="stat-box" style="border-left:4px solid ${KRI_STATUS[s][0]};"><div class="stat-value">${sum[s] || 0}</div><div class="stat-label">${KRI_STATUS[s][1]}</div></div>`).join('');
|
||||||
|
const body = document.getElementById('kri-table-body');
|
||||||
|
const kris = data.kris || [];
|
||||||
|
if (!kris.length) { body.innerHTML = '<tr><td colspan="7" class="text-muted" style="text-align:center;padding:20px;">Nessun KRI definito. Creane uno per monitorare gli indicatori di rischio.</td></tr>'; return; }
|
||||||
|
body.innerHTML = kris.map(k => {
|
||||||
|
const st = KRI_STATUS[k.status] || KRI_STATUS.unknown;
|
||||||
|
const val = k.current_value !== null ? `${(+k.current_value).toLocaleString('it-IT')} ${k.unit || ''}` : '—';
|
||||||
|
return `<tr>
|
||||||
|
<td><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:${st[0]};" title="${st[1]}"></span></td>
|
||||||
|
<td><strong>${escapeHtml(k.name)}</strong>${k.description ? `<br><small class="text-muted">${escapeHtml(k.description)}</small>` : ''}</td>
|
||||||
|
<td>${escapeHtml(CATEGORY_LABELS[k.category] || k.category || '')}</td>
|
||||||
|
<td><strong>${escapeHtml(val)}</strong></td>
|
||||||
|
<td>${k.threshold_warning ?? '—'}</td><td>${k.threshold_critical ?? '—'}</td>
|
||||||
|
<td><button class="btn btn-sm btn-secondary" onclick='showKriModal(${JSON.stringify(k)})'>Modifica</button></td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showKriModal(kri = null) {
|
||||||
|
const k = kri || {};
|
||||||
|
const body = `
|
||||||
|
<div class="form-group"><label class="form-label">Nome indicatore *</label><input class="form-input" id="kri-name" value="${escapeHtml(k.name || '')}"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Descrizione</label><input class="form-input" id="kri-desc" value="${escapeHtml(k.description || '')}"></div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||||
|
<div class="form-group"><label class="form-label">Categoria</label><select class="form-select" id="kri-cat">
|
||||||
|
${['cyber', 'operational', 'compliance', 'supply_chain', 'physical', 'human'].map(c => `<option value="${c}" ${k.category === c ? 'selected' : ''}>${CATEGORY_LABELS[c] || c}</option>`).join('')}</select></div>
|
||||||
|
<div class="form-group"><label class="form-label">Unita (%, count, EUR...)</label><input class="form-input" id="kri-unit" value="${escapeHtml(k.unit || '')}"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Valore corrente</label><input type="number" step="any" class="form-input" id="kri-cur" value="${k.current_value ?? ''}"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Obiettivo</label><input type="number" step="any" class="form-input" id="kri-target" value="${k.target_value ?? ''}"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Soglia Warning</label><input type="number" step="any" class="form-input" id="kri-warn" value="${k.threshold_warning ?? ''}"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Soglia Critica</label><input type="number" step="any" class="form-input" id="kri-crit" value="${k.threshold_critical ?? ''}"></div>
|
||||||
|
<div class="form-group" style="grid-column:span 2;"><label class="form-label">Direzione</label><select class="form-select" id="kri-dir">
|
||||||
|
<option value="higher_worse" ${k.direction === 'higher_worse' ? 'selected' : ''}>Valori alti = peggio</option>
|
||||||
|
<option value="lower_worse" ${k.direction === 'lower_worse' ? 'selected' : ''}>Valori bassi = peggio</option></select></div>
|
||||||
|
</div>`;
|
||||||
|
showModal(k.id ? 'Modifica KRI' : 'Nuovo KRI', body, {
|
||||||
|
footer: `<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveKri(${k.id || 'null'})">Salva</button>`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveKri(id) {
|
||||||
|
const data = {
|
||||||
|
name: document.getElementById('kri-name').value.trim(),
|
||||||
|
description: document.getElementById('kri-desc').value.trim(),
|
||||||
|
category: document.getElementById('kri-cat').value,
|
||||||
|
unit: document.getElementById('kri-unit').value.trim(),
|
||||||
|
current_value: document.getElementById('kri-cur').value,
|
||||||
|
target_value: document.getElementById('kri-target').value,
|
||||||
|
threshold_warning: document.getElementById('kri-warn').value,
|
||||||
|
threshold_critical: document.getElementById('kri-crit').value,
|
||||||
|
direction: document.getElementById('kri-dir').value,
|
||||||
|
};
|
||||||
|
if (!data.name) { showNotification('Nome obbligatorio', 'warning'); return; }
|
||||||
|
try {
|
||||||
|
const res = id ? await api.updateKri(id, data) : await api.createKri(data);
|
||||||
|
if (res.success) { closeModal(); showNotification('KRI salvato', 'success'); loadKri(); }
|
||||||
|
else showNotification(res.message || 'Errore salvataggio', 'error');
|
||||||
|
} catch (e) { showNotification('Errore di connessione', 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Matrix ───────────────────────────────────────────────────
|
// ── Matrix ───────────────────────────────────────────────────
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user