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>
929 lines
45 KiB
HTML
929 lines
45 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="it">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Inventario Asset - NIS2 Agile</title>
|
|
<link rel="stylesheet" href="/css/style.css">
|
|
<style>
|
|
/* ── Stats Cards ────────────────────────────────────────── */
|
|
.asset-stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.asset-stat-card {
|
|
background: var(--card-bg);
|
|
border-radius: var(--border-radius);
|
|
box-shadow: var(--card-shadow);
|
|
padding: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
.asset-stat-icon {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: var(--border-radius);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.asset-stat-icon svg { width: 24px; height: 24px; }
|
|
.asset-stat-icon.primary { background: var(--primary-bg); color: var(--primary); }
|
|
.asset-stat-icon.success { background: var(--secondary-bg); color: var(--secondary); }
|
|
.asset-stat-icon.danger { background: var(--danger-bg); color: var(--danger); }
|
|
.asset-stat-icon.warning { background: var(--warning-bg); color: var(--gray-600); }
|
|
.asset-stat-value {
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
color: var(--gray-800);
|
|
line-height: 1.2;
|
|
}
|
|
.asset-stat-label {
|
|
font-size: 0.8rem;
|
|
color: var(--gray-500);
|
|
}
|
|
|
|
/* ── Filters Bar ────────────────────────────────────────── */
|
|
.filters-bar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 12px;
|
|
margin-bottom: 20px;
|
|
align-items: center;
|
|
}
|
|
.filters-bar select {
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--gray-200);
|
|
border-radius: var(--border-radius-sm);
|
|
background: var(--card-bg);
|
|
color: var(--gray-700);
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
}
|
|
.filters-bar select:focus {
|
|
outline: none;
|
|
border-color: var(--primary);
|
|
box-shadow: 0 0 0 3px var(--primary-bg);
|
|
}
|
|
|
|
/* ── Data Table ─────────────────────────────────────────── */
|
|
.data-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
background: var(--card-bg);
|
|
border-radius: var(--border-radius);
|
|
overflow: hidden;
|
|
box-shadow: var(--card-shadow);
|
|
}
|
|
.data-table thead {
|
|
background: var(--gray-50);
|
|
}
|
|
.data-table th {
|
|
text-align: left;
|
|
padding: 12px 16px;
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
color: var(--gray-500);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
border-bottom: 1px solid var(--gray-200);
|
|
}
|
|
.data-table td {
|
|
padding: 12px 16px;
|
|
font-size: 0.875rem;
|
|
color: var(--gray-700);
|
|
border-bottom: 1px solid var(--gray-100);
|
|
}
|
|
.data-table tr:last-child td { border-bottom: none; }
|
|
.data-table tbody tr { cursor: pointer; transition: background var(--transition-fast); }
|
|
.data-table tbody tr:hover td { background: var(--gray-50); }
|
|
|
|
/* ── Criticality Badges ─────────────────────────────────── */
|
|
.criticality-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 3px 10px;
|
|
border-radius: 20px;
|
|
font-size: 0.78rem;
|
|
font-weight: 500;
|
|
}
|
|
.criticality-badge.critical { background: var(--danger-bg); color: var(--danger); }
|
|
.criticality-badge.high { background: #fff3e0; color: #e65100; }
|
|
.criticality-badge.medium { background: var(--warning-bg); color: #b8860b; }
|
|
.criticality-badge.low { background: var(--secondary-bg); color: var(--secondary); }
|
|
|
|
/* ── Status Badge ───────────────────────────────────────── */
|
|
.status-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 3px 10px;
|
|
border-radius: 20px;
|
|
font-size: 0.78rem;
|
|
font-weight: 500;
|
|
}
|
|
.status-badge.active { background: var(--secondary-bg); color: var(--secondary); }
|
|
.status-badge.maintenance { background: var(--warning-bg); color: #b8860b; }
|
|
.status-badge.decommissioned { background: var(--gray-100); color: var(--gray-500); }
|
|
|
|
/* ── Asset Detail Panel ─────────────────────────────────── */
|
|
.detail-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 16px;
|
|
}
|
|
.detail-field {
|
|
margin-bottom: 4px;
|
|
}
|
|
.detail-field-label {
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: var(--gray-500);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 2px;
|
|
}
|
|
.detail-field-value {
|
|
font-size: 0.9rem;
|
|
color: var(--gray-800);
|
|
}
|
|
.detail-section-title {
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
color: var(--gray-700);
|
|
margin: 20px 0 12px;
|
|
padding-bottom: 6px;
|
|
border-bottom: 1px solid var(--gray-200);
|
|
}
|
|
.dep-tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
background: var(--primary-bg);
|
|
color: var(--primary);
|
|
padding: 4px 10px;
|
|
border-radius: 20px;
|
|
font-size: 0.8rem;
|
|
margin: 2px 4px 2px 0;
|
|
}
|
|
|
|
/* ── Action Buttons ─────────────────────────────────────── */
|
|
.btn-sm {
|
|
padding: 5px 12px;
|
|
font-size: 0.8rem;
|
|
}
|
|
.btn-icon {
|
|
padding: 6px;
|
|
background: none;
|
|
border: none;
|
|
color: var(--gray-400);
|
|
cursor: pointer;
|
|
border-radius: var(--border-radius-sm);
|
|
transition: all var(--transition-fast);
|
|
}
|
|
.btn-icon:hover { color: var(--primary); background: var(--primary-bg); }
|
|
.btn-icon svg { width: 18px; height: 18px; }
|
|
|
|
/* ── Type breakdown chips ───────────────────────────────── */
|
|
.type-chips {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
margin-top: 8px;
|
|
}
|
|
.type-chip {
|
|
background: var(--gray-100);
|
|
padding: 3px 10px;
|
|
border-radius: 20px;
|
|
font-size: 0.75rem;
|
|
color: var(--gray-600);
|
|
}
|
|
.type-chip strong {
|
|
color: var(--gray-800);
|
|
margin-right: 3px;
|
|
}
|
|
|
|
/* ── Loading/Empty ──────────────────────────────────────── */
|
|
.loading-state {
|
|
text-align: center;
|
|
padding: 48px 20px;
|
|
color: var(--gray-400);
|
|
}
|
|
.loading-state .spinner {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: 3px solid var(--gray-200);
|
|
border-top-color: var(--primary);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
margin: 0 auto 12px;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
.empty-state-box {
|
|
text-align: center;
|
|
padding: 48px 20px;
|
|
color: var(--gray-400);
|
|
}
|
|
.empty-state-box svg {
|
|
width: 48px;
|
|
height: 48px;
|
|
margin-bottom: 12px;
|
|
opacity: 0.5;
|
|
}
|
|
.empty-state-box h4 {
|
|
color: var(--gray-600);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
/* ── Modal form enhancements ───────────────────────────── */
|
|
.form-grid-2 {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 16px;
|
|
}
|
|
|
|
/* ── Responsive ─────────────────────────────────────────── */
|
|
@media (max-width: 768px) {
|
|
.asset-stats { grid-template-columns: 1fr 1fr; }
|
|
.detail-grid { grid-template-columns: 1fr; }
|
|
.filters-bar { flex-direction: column; }
|
|
.form-grid-2 { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app-layout">
|
|
<aside class="sidebar" id="sidebar"></aside>
|
|
|
|
<main class="main-content">
|
|
<header class="content-header">
|
|
<h2 data-i18n="assets.title">Inventario Asset</h2>
|
|
<div class="content-header-actions">
|
|
<span class="text-muted" style="font-size:0.8rem; margin-right:8px;">Art. 21.2.i NIS2</span>
|
|
<button class="btn btn-primary" onclick="showCreateAssetModal()">+ Nuovo Asset</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="content-body">
|
|
<!-- Stats Cards -->
|
|
<div class="asset-stats" id="asset-stats">
|
|
<div class="asset-stat-card">
|
|
<div class="asset-stat-icon primary">
|
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm14 1a1 1 0 11-2 0 1 1 0 012 0zM2 13a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2v-2zm14 1a1 1 0 11-2 0 1 1 0 012 0z" clip-rule="evenodd"/></svg>
|
|
</div>
|
|
<div>
|
|
<div class="asset-stat-value" id="stat-total">--</div>
|
|
<div class="asset-stat-label">Totale Asset</div>
|
|
</div>
|
|
</div>
|
|
<div class="asset-stat-card">
|
|
<div class="asset-stat-icon success">
|
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>
|
|
</div>
|
|
<div>
|
|
<div class="asset-stat-value" id="stat-types">--</div>
|
|
<div class="asset-stat-label">Tipologie</div>
|
|
<div class="type-chips" id="stat-type-chips"></div>
|
|
</div>
|
|
</div>
|
|
<div class="asset-stat-card">
|
|
<div class="asset-stat-icon danger">
|
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
|
</div>
|
|
<div>
|
|
<div class="asset-stat-value" id="stat-critical">--</div>
|
|
<div class="asset-stat-label">Asset Critici</div>
|
|
</div>
|
|
</div>
|
|
<div class="asset-stat-card">
|
|
<div class="asset-stat-icon warning">
|
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>
|
|
</div>
|
|
<div>
|
|
<div class="asset-stat-value" id="stat-decommissioned">--</div>
|
|
<div class="asset-stat-label">Dismessi</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="filters-bar">
|
|
<select id="filter-type" onchange="applyFilters()">
|
|
<option value="">Tutti i tipi</option>
|
|
<option value="hardware">Hardware</option>
|
|
<option value="software">Software</option>
|
|
<option value="network">Rete</option>
|
|
<option value="data">Dati</option>
|
|
<option value="service">Servizio</option>
|
|
<option value="personnel">Personale</option>
|
|
<option value="facility">Struttura</option>
|
|
</select>
|
|
<select id="filter-criticality" onchange="applyFilters()">
|
|
<option value="">Tutte le criticita'</option>
|
|
<option value="critical">Critica</option>
|
|
<option value="high">Alta</option>
|
|
<option value="medium">Media</option>
|
|
<option value="low">Bassa</option>
|
|
</select>
|
|
<select id="filter-status" onchange="applyFilters()">
|
|
<option value="">Tutti gli stati</option>
|
|
<option value="active">Attivo</option>
|
|
<option value="maintenance">Manutenzione</option>
|
|
<option value="decommissioned">Dismesso</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Asset Table -->
|
|
<div id="assets-container">
|
|
<div class="loading-state">
|
|
<div class="spinner"></div>
|
|
<p>Caricamento asset...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<script src="/js/api.js"></script>
|
|
<script src="/js/common.js"></script>
|
|
<script src="/js/i18n.js"></script>
|
|
<script src="/js/help.js"></script>
|
|
<script>
|
|
// ── Auth & Init ─────────────────────────────────────────
|
|
if (!checkAuth()) throw new Error('Not authenticated');
|
|
loadSidebar();
|
|
I18n.init();
|
|
HelpSystem.init();
|
|
|
|
// ── Labels ──────────────────────────────────────────────
|
|
const typeLabels = {
|
|
hardware: 'Hardware',
|
|
software: 'Software',
|
|
network: 'Rete',
|
|
data: 'Dati',
|
|
service: 'Servizio',
|
|
personnel: 'Personale',
|
|
facility: 'Struttura'
|
|
};
|
|
|
|
const criticalityLabels = {
|
|
critical: 'Critica',
|
|
high: 'Alta',
|
|
medium: 'Media',
|
|
low: 'Bassa'
|
|
};
|
|
|
|
const statusLabels = {
|
|
active: 'Attivo',
|
|
maintenance: 'Manutenzione',
|
|
decommissioned: 'Dismesso'
|
|
};
|
|
|
|
// ── State ───────────────────────────────────────────────
|
|
let allAssets = [];
|
|
|
|
// ── Load Assets ─────────────────────────────────────────
|
|
async function loadAssets() {
|
|
const container = document.getElementById('assets-container');
|
|
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento asset...</p></div>';
|
|
|
|
const params = {};
|
|
const type = document.getElementById('filter-type').value;
|
|
const criticality = document.getElementById('filter-criticality').value;
|
|
const status = document.getElementById('filter-status').value;
|
|
if (type) params.asset_type = type;
|
|
if (criticality) params.criticality = criticality;
|
|
if (status) params.status = status;
|
|
|
|
try {
|
|
const result = await api.listAssets(params);
|
|
if (result.success) {
|
|
allAssets = result.data || [];
|
|
updateStats(allAssets);
|
|
renderAssets(allAssets);
|
|
} else {
|
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore nel caricamento</h4><p>' + escapeHtml(result.message || '') + '</p></div>';
|
|
}
|
|
} catch (e) {
|
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4><p>Impossibile caricare gli asset.</p></div>';
|
|
}
|
|
}
|
|
|
|
function applyFilters() {
|
|
loadAssets();
|
|
}
|
|
|
|
function updateStats(assets) {
|
|
// For stats, we count from the current filtered results
|
|
// In a full implementation, stats would come from the server unfiltered
|
|
document.getElementById('stat-total').textContent = assets.length;
|
|
|
|
// Type breakdown
|
|
const typeCount = {};
|
|
let criticalCount = 0;
|
|
let decommCount = 0;
|
|
assets.forEach(a => {
|
|
const t = a.asset_type || 'unknown';
|
|
typeCount[t] = (typeCount[t] || 0) + 1;
|
|
if (a.criticality === 'critical') criticalCount++;
|
|
if (a.status === 'decommissioned') decommCount++;
|
|
});
|
|
|
|
const uniqueTypes = Object.keys(typeCount).length;
|
|
document.getElementById('stat-types').textContent = uniqueTypes;
|
|
document.getElementById('stat-critical').textContent = criticalCount;
|
|
document.getElementById('stat-decommissioned').textContent = decommCount;
|
|
|
|
// Type chips
|
|
const chipsEl = document.getElementById('stat-type-chips');
|
|
chipsEl.innerHTML = Object.entries(typeCount).map(([type, count]) =>
|
|
`<span class="type-chip"><strong>${count}</strong> ${typeLabels[type] || type}</span>`
|
|
).join('');
|
|
}
|
|
|
|
function renderAssets(assets) {
|
|
const container = document.getElementById('assets-container');
|
|
|
|
if (!assets || assets.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state-box">
|
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm14 1a1 1 0 11-2 0 1 1 0 012 0zM2 13a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2v-2zm14 1a1 1 0 11-2 0 1 1 0 012 0z" clip-rule="evenodd"/></svg>
|
|
<h4>Nessun asset trovato</h4>
|
|
<p>Registra il primo asset per costruire l'inventario.</p>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
let html = `
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Nome</th>
|
|
<th>Tipo</th>
|
|
<th>Categoria</th>
|
|
<th>Criticita'</th>
|
|
<th>Rilevanza NIS2</th>
|
|
<th>Owner</th>
|
|
<th>Stato</th>
|
|
<th>Azioni</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>`;
|
|
|
|
assets.forEach(asset => {
|
|
const crit = asset.criticality || 'medium';
|
|
const st = asset.status || 'active';
|
|
html += `
|
|
<tr onclick="showAssetDetail(${asset.id})" title="Visualizza dettagli">
|
|
<td style="font-weight:500;">${escapeHtml(asset.name)}</td>
|
|
<td>${typeLabels[asset.asset_type] || asset.asset_type || '-'}</td>
|
|
<td>${escapeHtml(asset.category || '-')}</td>
|
|
<td><span class="criticality-badge ${crit}">${criticalityLabels[crit] || crit}</span></td>
|
|
<td>${relevanceBadge(asset)}</td>
|
|
<td>${escapeHtml(asset.owner_name || '-')}</td>
|
|
<td><span class="status-badge ${st}">${statusLabels[st] || st}</span></td>
|
|
<td style="white-space:nowrap;">
|
|
<button class="btn-icon" onclick="event.stopPropagation(); showScoringModal(${asset.id})" title="Valuta rilevanza NIS2">
|
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 3a1 1 0 011 1v12h12a1 1 0 110 2H3a1 1 0 01-1-1V4a1 1 0 011-1zm14.707 4.707a1 1 0 00-1.414-1.414L12 10.586 9.707 8.293a1 1 0 00-1.414 0L4.586 12 6 13.414l2.293-2.293L10.586 13l5.121-5.293z" clip-rule="evenodd"/></svg>
|
|
</button>
|
|
<button class="btn-icon" onclick="event.stopPropagation(); showEditAssetModal(${asset.id})" title="Modifica">
|
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
|
|
</button>
|
|
</td>
|
|
</tr>`;
|
|
});
|
|
|
|
html += '</tbody></table>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
// ── Rilevanza NIS2 (GV.OC-04) ───────────────────────────
|
|
const relevanceClassColors = {
|
|
critico: '#dc2626', alto: '#ea580c', medio: '#ca8a04',
|
|
basso: '#2563eb', trascurabile: '#6b7280'
|
|
};
|
|
|
|
function relevanceBadge(asset) {
|
|
if (asset.relevance_score === null || asset.relevance_score === undefined || asset.relevance_score === '') {
|
|
return '<span style="color:var(--gray-400); font-size:0.8rem;">Da valutare</span>';
|
|
}
|
|
const cls = asset.relevance_class || 'trascurabile';
|
|
const color = relevanceClassColors[cls] || '#6b7280';
|
|
const rel = Number(asset.is_nis2_relevant) ? ' ✓' : '';
|
|
return `<span style="display:inline-flex;align-items:center;gap:6px;font-size:0.8rem;font-weight:600;color:${color};">
|
|
<span style="display:inline-block;min-width:30px;text-align:center;padding:2px 6px;border-radius:6px;background:${color}1a;">${asset.relevance_score}</span>
|
|
${cls.charAt(0).toUpperCase() + cls.slice(1)}${rel}</span>`;
|
|
}
|
|
|
|
let _scoringGrid = null;
|
|
async function loadScoringGrid() {
|
|
if (_scoringGrid) return _scoringGrid;
|
|
const r = await api.getScoringGrid();
|
|
if (r.success) _scoringGrid = r.data;
|
|
return _scoringGrid;
|
|
}
|
|
|
|
async function showScoringModal(id) {
|
|
try {
|
|
const [assetRes, grid] = await Promise.all([api.getAsset(id), loadScoringGrid()]);
|
|
if (!assetRes.success || !grid) { showNotification('Errore caricamento dati.', 'error'); return; }
|
|
const a = assetRes.data;
|
|
let prev = a.relevance_criteria;
|
|
if (typeof prev === 'string') { try { prev = JSON.parse(prev); } catch (e) { prev = null; } }
|
|
|
|
let body = `<p style="font-size:0.85rem;color:var(--gray-600);margin-bottom:1rem;">
|
|
Metodologia di scoring rilevanza NIS2 (requisito <strong>GV.OC-04</strong>). Soglia rilevanza: <strong>≥${grid.threshold} punti</strong>.
|
|
Il punteggio aggiorna automaticamente anche la criticita dell'asset.</p>`;
|
|
|
|
for (const [key, def] of Object.entries(grid.grid)) {
|
|
const sel = prev && prev[key] ? prev[key].value : '';
|
|
let opts = `<option value="">— seleziona —</option>`;
|
|
for (const [ov, od] of Object.entries(def.options)) {
|
|
opts += `<option value="${ov}" data-pts="${od.points}" ${ov === sel ? 'selected' : ''}>${od.label} (${od.points})</option>`;
|
|
}
|
|
body += `<div class="form-group" style="margin-bottom:0.75rem;">
|
|
<label class="form-label" style="font-weight:600;">${def.label} <span style="color:var(--gray-400);font-weight:400;">(max ${def.max})</span></label>
|
|
<div style="font-size:0.78rem;color:var(--gray-500);margin-bottom:4px;">${def.help}</div>
|
|
<select class="form-select score-criterion" data-key="${key}" onchange="updateScorePreview()">${opts}</select>
|
|
</div>`;
|
|
}
|
|
body += `<div id="score-preview" style="margin-top:1rem;padding:0.9rem;border-radius:10px;background:var(--gray-50);font-weight:600;text-align:center;">
|
|
Totale: <span id="score-total">0</span>/100 — <span id="score-class">—</span></div>`;
|
|
|
|
showModal(`Valuta Rilevanza NIS2 — ${escapeHtml(a.name)}`, body, {
|
|
size: 'lg',
|
|
footer: `<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
|
<button class="btn btn-primary" onclick="submitScoring(${id})">Calcola e Salva</button>`
|
|
});
|
|
updateScorePreview();
|
|
} catch (e) {
|
|
showNotification('Errore nell\'apertura della valutazione.', 'error');
|
|
}
|
|
}
|
|
|
|
function updateScorePreview() {
|
|
let total = 0, complete = true;
|
|
document.querySelectorAll('.score-criterion').forEach(s => {
|
|
if (!s.value) { complete = false; return; }
|
|
total += parseInt(s.selectedOptions[0].dataset.pts || '0', 10);
|
|
});
|
|
let cls = total >= 80 ? 'critico' : total >= 60 ? 'alto' : total >= 40 ? 'medio' : total >= 20 ? 'basso' : 'trascurabile';
|
|
const color = relevanceClassColors[cls];
|
|
document.getElementById('score-total').textContent = total;
|
|
const clsEl = document.getElementById('score-class');
|
|
clsEl.textContent = complete ? `${cls.charAt(0).toUpperCase() + cls.slice(1)}${total >= 40 ? ' — Rilevante NIS2 ✓' : ''}` : '(completa tutti i criteri)';
|
|
clsEl.style.color = complete ? color : 'var(--gray-400)';
|
|
}
|
|
|
|
async function submitScoring(id) {
|
|
const criteria = {};
|
|
let complete = true;
|
|
document.querySelectorAll('.score-criterion').forEach(s => {
|
|
if (!s.value) complete = false;
|
|
criteria[s.dataset.key] = s.value;
|
|
});
|
|
if (!complete) { showNotification('Compila tutti i 6 criteri.', 'warning'); return; }
|
|
try {
|
|
const r = await api.scoreAsset(id, criteria);
|
|
if (r.success) {
|
|
showNotification(`Rilevanza calcolata: ${r.data.score}/100 (${r.data.class}).`, 'success');
|
|
closeModal();
|
|
loadAssets();
|
|
} else {
|
|
showNotification(r.message || 'Errore nel calcolo.', 'error');
|
|
}
|
|
} catch (e) {
|
|
showNotification('Errore di connessione.', 'error');
|
|
}
|
|
}
|
|
|
|
// ── Asset Detail View ───────────────────────────────────
|
|
async function showAssetDetail(id) {
|
|
try {
|
|
const result = await api.getAsset(id);
|
|
if (!result.success || !result.data) {
|
|
showNotification('Asset non trovato.', 'error');
|
|
return;
|
|
}
|
|
|
|
const a = result.data;
|
|
const crit = a.criticality || 'medium';
|
|
const st = a.status || 'active';
|
|
|
|
let depsHtml = '<span style="color:var(--gray-400);">Nessuna dipendenza</span>';
|
|
if (a.dependencies) {
|
|
let deps = a.dependencies;
|
|
if (typeof deps === 'string') {
|
|
try { deps = JSON.parse(deps); } catch(e) { deps = []; }
|
|
}
|
|
if (deps && deps.length > 0) {
|
|
depsHtml = deps.map(d => `<span class="dep-tag">ID: ${escapeHtml(String(d))}</span>`).join('');
|
|
}
|
|
}
|
|
|
|
showModal(escapeHtml(a.name), `
|
|
<div class="detail-grid">
|
|
<div class="detail-field">
|
|
<div class="detail-field-label">Tipo</div>
|
|
<div class="detail-field-value">${typeLabels[a.asset_type] || a.asset_type || '-'}</div>
|
|
</div>
|
|
<div class="detail-field">
|
|
<div class="detail-field-label">Categoria</div>
|
|
<div class="detail-field-value">${escapeHtml(a.category || '-')}</div>
|
|
</div>
|
|
<div class="detail-field">
|
|
<div class="detail-field-label">Criticita'</div>
|
|
<div class="detail-field-value"><span class="criticality-badge ${crit}">${criticalityLabels[crit] || crit}</span></div>
|
|
</div>
|
|
<div class="detail-field">
|
|
<div class="detail-field-label">Stato</div>
|
|
<div class="detail-field-value"><span class="status-badge ${st}">${statusLabels[st] || st}</span></div>
|
|
</div>
|
|
<div class="detail-field">
|
|
<div class="detail-field-label">Owner</div>
|
|
<div class="detail-field-value">${escapeHtml(a.owner_name || '-')}</div>
|
|
</div>
|
|
<div class="detail-field">
|
|
<div class="detail-field-label">Posizione</div>
|
|
<div class="detail-field-value">${escapeHtml(a.location || '-')}</div>
|
|
</div>
|
|
<div class="detail-field">
|
|
<div class="detail-field-label">Indirizzo IP</div>
|
|
<div class="detail-field-value">${escapeHtml(a.ip_address || '-')}</div>
|
|
</div>
|
|
<div class="detail-field">
|
|
<div class="detail-field-label">Fornitore</div>
|
|
<div class="detail-field-value">${escapeHtml(a.vendor || '-')}</div>
|
|
</div>
|
|
<div class="detail-field">
|
|
<div class="detail-field-label">Versione</div>
|
|
<div class="detail-field-value">${escapeHtml(a.version || '-')}</div>
|
|
</div>
|
|
<div class="detail-field">
|
|
<div class="detail-field-label">Numero Seriale</div>
|
|
<div class="detail-field-value">${escapeHtml(a.serial_number || '-')}</div>
|
|
</div>
|
|
<div class="detail-field">
|
|
<div class="detail-field-label">Data Acquisto</div>
|
|
<div class="detail-field-value">${formatDate(a.purchase_date)}</div>
|
|
</div>
|
|
<div class="detail-field">
|
|
<div class="detail-field-label">Scadenza Garanzia</div>
|
|
<div class="detail-field-value">${formatDate(a.warranty_expiry)}</div>
|
|
</div>
|
|
</div>
|
|
${a.description ? `
|
|
<div class="detail-section-title">Descrizione</div>
|
|
<p style="font-size:0.9rem; color:var(--gray-600);">${escapeHtml(a.description)}</p>
|
|
` : ''}
|
|
<div class="detail-section-title">Dipendenze</div>
|
|
<div>${depsHtml}</div>
|
|
`, {
|
|
size: 'lg',
|
|
footer: `
|
|
<button class="btn btn-secondary" onclick="closeModal()">Chiudi</button>
|
|
<button class="btn btn-secondary" onclick="closeModal(); showScoringModal(${a.id})">Valuta Rilevanza NIS2</button>
|
|
<button class="btn btn-primary" onclick="closeModal(); showEditAssetModal(${a.id})">Modifica</button>
|
|
`
|
|
});
|
|
} catch (e) {
|
|
showNotification('Errore nel caricamento dell\'asset.', 'error');
|
|
}
|
|
}
|
|
|
|
// ── Create Asset Modal ──────────────────────────────────
|
|
function showCreateAssetModal() {
|
|
showAssetModal('Nuovo Asset', {}, async function(data) {
|
|
try {
|
|
const result = await api.createAsset(data);
|
|
if (result.success) {
|
|
showNotification('Asset registrato con successo!', 'success');
|
|
closeModal();
|
|
loadAssets();
|
|
} else {
|
|
showNotification(result.message || 'Errore nella creazione.', 'error');
|
|
}
|
|
} catch (e) {
|
|
showNotification('Errore di connessione.', 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Edit Asset Modal ────────────────────────────────────
|
|
async function showEditAssetModal(id) {
|
|
try {
|
|
const result = await api.getAsset(id);
|
|
if (!result.success || !result.data) {
|
|
showNotification('Asset non trovato.', 'error');
|
|
return;
|
|
}
|
|
|
|
showAssetModal('Modifica Asset', result.data, async function(data) {
|
|
try {
|
|
const res = await api.updateAsset(id, data);
|
|
if (res.success) {
|
|
showNotification('Asset aggiornato con successo!', 'success');
|
|
closeModal();
|
|
loadAssets();
|
|
} else {
|
|
showNotification(res.message || 'Errore nell\'aggiornamento.', 'error');
|
|
}
|
|
} catch (e) {
|
|
showNotification('Errore di connessione.', 'error');
|
|
}
|
|
});
|
|
} catch (e) {
|
|
showNotification('Errore nel caricamento.', 'error');
|
|
}
|
|
}
|
|
|
|
// ── Generic Asset Modal ─────────────────────────────────
|
|
function showAssetModal(title, asset, onSave) {
|
|
const a = asset || {};
|
|
|
|
const typeOptions = Object.entries(typeLabels).map(([val, label]) =>
|
|
`<option value="${val}" ${a.asset_type === val ? 'selected' : ''}>${label}</option>`
|
|
).join('');
|
|
|
|
const critOptions = Object.entries(criticalityLabels).map(([val, label]) =>
|
|
`<option value="${val}" ${a.criticality === val ? 'selected' : ''}>${label}</option>`
|
|
).join('');
|
|
|
|
const statusOptions = Object.entries(statusLabels).map(([val, label]) =>
|
|
`<option value="${val}" ${a.status === val ? 'selected' : ''}>${label}</option>`
|
|
).join('');
|
|
|
|
// Store the callback
|
|
window._assetSaveCallback = onSave;
|
|
|
|
showModal(title, `
|
|
<div class="form-group">
|
|
<label class="form-label">Nome *</label>
|
|
<input type="text" class="form-input" id="asset-name" value="${escapeHtml(a.name || '')}" required>
|
|
</div>
|
|
<div class="form-grid-2">
|
|
<div class="form-group">
|
|
<label class="form-label">Tipo *</label>
|
|
<select class="form-select" id="asset-type">${typeOptions}</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Categoria</label>
|
|
<input type="text" class="form-input" id="asset-category" value="${escapeHtml(a.category || '')}" placeholder="Es: Server, Database...">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Descrizione</label>
|
|
<textarea class="form-input" id="asset-description" rows="2">${escapeHtml(a.description || '')}</textarea>
|
|
</div>
|
|
<div class="form-grid-2">
|
|
<div class="form-group">
|
|
<label class="form-label">Criticita'</label>
|
|
<select class="form-select" id="asset-criticality">${critOptions}</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Stato</label>
|
|
<select class="form-select" id="asset-status">${statusOptions}</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-grid-2">
|
|
<div class="form-group">
|
|
<label class="form-label">Owner (ID utente)</label>
|
|
<input type="text" class="form-input" id="asset-owner" value="${escapeHtml(a.owner_user_id || '')}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Posizione</label>
|
|
<input type="text" class="form-input" id="asset-location" value="${escapeHtml(a.location || '')}" placeholder="Es: Data Center Roma">
|
|
</div>
|
|
</div>
|
|
<div class="form-grid-2">
|
|
<div class="form-group">
|
|
<label class="form-label">Indirizzo IP</label>
|
|
<input type="text" class="form-input" id="asset-ip" value="${escapeHtml(a.ip_address || '')}" placeholder="Es: 192.168.1.100">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Fornitore</label>
|
|
<input type="text" class="form-input" id="asset-vendor" value="${escapeHtml(a.vendor || '')}">
|
|
</div>
|
|
</div>
|
|
<div class="form-grid-2">
|
|
<div class="form-group">
|
|
<label class="form-label">Versione</label>
|
|
<input type="text" class="form-input" id="asset-version" value="${escapeHtml(a.version || '')}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Numero Seriale</label>
|
|
<input type="text" class="form-input" id="asset-serial" value="${escapeHtml(a.serial_number || '')}">
|
|
</div>
|
|
</div>
|
|
<div class="form-grid-2">
|
|
<div class="form-group">
|
|
<label class="form-label">Data Acquisto</label>
|
|
<input type="date" class="form-input" id="asset-purchase-date" value="${a.purchase_date || ''}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Scadenza Garanzia</label>
|
|
<input type="date" class="form-input" id="asset-warranty" value="${a.warranty_expiry || ''}">
|
|
</div>
|
|
</div>
|
|
`, {
|
|
size: 'lg',
|
|
footer: `
|
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
|
<button class="btn btn-primary" onclick="saveAsset()">Salva</button>
|
|
`
|
|
});
|
|
}
|
|
|
|
function saveAsset() {
|
|
const name = document.getElementById('asset-name').value.trim();
|
|
if (!name) {
|
|
showNotification('Il nome e\' obbligatorio.', 'warning');
|
|
return;
|
|
}
|
|
|
|
const data = {
|
|
name: name,
|
|
asset_type: document.getElementById('asset-type').value,
|
|
category: document.getElementById('asset-category').value.trim() || null,
|
|
description: document.getElementById('asset-description').value.trim() || null,
|
|
criticality: document.getElementById('asset-criticality').value,
|
|
owner_user_id: document.getElementById('asset-owner').value.trim() || null,
|
|
location: document.getElementById('asset-location').value.trim() || null,
|
|
ip_address: document.getElementById('asset-ip').value.trim() || null,
|
|
vendor: document.getElementById('asset-vendor').value.trim() || null,
|
|
version: document.getElementById('asset-version').value.trim() || null,
|
|
serial_number: document.getElementById('asset-serial').value.trim() || null,
|
|
purchase_date: document.getElementById('asset-purchase-date').value || null,
|
|
warranty_expiry: document.getElementById('asset-warranty').value || null,
|
|
status: document.getElementById('asset-status').value,
|
|
};
|
|
|
|
if (window._assetSaveCallback) {
|
|
window._assetSaveCallback(data);
|
|
}
|
|
}
|
|
|
|
// ── Initial Load ────────────────────────────────────────
|
|
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>
|
|
</body>
|
|
</html>
|