nis2-agile/public/assets.html
DevEnv nis2-agile 7a23789b0f [FIX] ri-review guida: 2 fix guida + 2 fix prodotto UI orfane
cap-5: sei->quattro modalita + chiosa adeguatezza-ruolo sotto GV.RR-04 (non GV.PO-01)
cap-7: nomi bottoni allineati alla UI (Invia Report Finale 30gg)
assets.html: bottone Importa (openImportModal era orfana)
reports.html: tab Requisiti ACN (loadAcnRequirements -> endpoint esistente, 87/116 per entity_type)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:51:30 +02:00

930 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-secondary" onclick="openImportModal()" title="Importa asset da CSV/CMDB">Importa</button>
<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>&ge;${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||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>