Porting voce TRPG/AllTax (ElevenLabs "Sarah") sull'ARIA chat NIS2: - AiController::tts() + route POST /api/ai/tts: proxy same-origin a nexus-voice-ms (config VOICE_MS_URL=172.21.0.1:4215, voce TTS_VOICE_ID=EXAVITQu4vr4xnSDxMaL, eleven_turbo_v2_5). requireAuth, cache MP3 sha256 (sys_get_temp_dir/nis2-tts-cache), validazioni (text<=800, voice_id allowlist), stream audio/mpeg, 502 se upstream non-audio. - common.js: speak(answer) dopo ogni risposta ARIA (voce discreta vol .55, markdown strip), toggle 🔊/🔇 nell'header (muto persistito localStorage nis2_aria_voice), autoplay best-effort. Smoke: voice-ms da nis2-app HTTP 200 audio/mpeg 19KB; /api/ai/tts 401 senza auth. Reachability OK (no allowlist VOX necessaria). Cache-buster common.js -> 20260624g. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
412 lines
19 KiB
HTML
412 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="it">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<title>Pannello Amministrazione - NIS2 Agile</title>
|
|
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
|
|
cosi' style.css resta autorevole per i widget renderizzati via JS -> parita' funzionale.
|
|
Pagina in /admin/: gli asset usano path ASSOLUTI (/vendor, /js, /css) serviti dal web root. -->
|
|
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
|
<link rel="stylesheet" href="/css/style.css?v=20260618">
|
|
<style>
|
|
.admin-stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.admin-stat-card {
|
|
background: var(--card-bg);
|
|
border-radius: var(--border-radius-lg);
|
|
box-shadow: var(--card-shadow);
|
|
padding: 20px;
|
|
transition: box-shadow var(--transition), transform var(--transition);
|
|
}
|
|
.admin-stat-card:hover {
|
|
box-shadow: var(--card-shadow-hover);
|
|
transform: translateY(-2px);
|
|
}
|
|
.admin-stat-label {
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--gray-500);
|
|
margin-bottom: 8px;
|
|
}
|
|
.admin-stat-value {
|
|
font-size: 2rem;
|
|
font-weight: 800;
|
|
color: var(--gray-900);
|
|
line-height: 1;
|
|
}
|
|
.admin-stat-icon {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: var(--border-radius);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
.admin-stat-icon svg {
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
.admin-stat-icon.blue { background: var(--primary-bg); color: var(--primary); }
|
|
.admin-stat-icon.green { background: var(--secondary-bg); color: var(--secondary); }
|
|
.admin-stat-icon.orange { background: var(--warning-bg); color: #d97706; }
|
|
.admin-stat-icon.red { background: var(--danger-bg); color: var(--danger); }
|
|
.admin-stat-icon.purple { background: #f3e8ff; color: #7c3aed; }
|
|
/* Le icone BI usano fill=currentColor: garantiamo che il colore del wrapper sia ereditato. */
|
|
.admin-stat-icon svg.icon { fill: currentColor; width: 20px; height: 20px; }
|
|
.chart-container {
|
|
padding: 24px;
|
|
}
|
|
.bar-chart {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 32px;
|
|
height: 200px;
|
|
padding: 0 20px;
|
|
border-bottom: 2px solid var(--gray-200);
|
|
}
|
|
.bar-chart-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
flex: 1;
|
|
height: 100%;
|
|
justify-content: flex-end;
|
|
}
|
|
.bar-chart-bar {
|
|
width: 60px;
|
|
border-radius: 6px 6px 0 0;
|
|
transition: height 0.8s ease;
|
|
min-height: 4px;
|
|
position: relative;
|
|
}
|
|
.bar-chart-bar.free { background: var(--gray-400); }
|
|
.bar-chart-bar.professional { background: var(--primary); }
|
|
.bar-chart-bar.enterprise { background: #7c3aed; }
|
|
.bar-chart-value {
|
|
font-size: 0.875rem;
|
|
font-weight: 700;
|
|
color: var(--gray-700);
|
|
margin-bottom: 8px;
|
|
}
|
|
.bar-chart-label {
|
|
font-size: 0.75rem;
|
|
color: var(--gray-500);
|
|
margin-top: 12px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
.quick-link-card {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 20px 24px;
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--gray-200);
|
|
border-radius: var(--border-radius-lg);
|
|
cursor: pointer;
|
|
transition: all var(--transition-fast);
|
|
text-decoration: none;
|
|
color: inherit;
|
|
}
|
|
.quick-link-card:hover {
|
|
border-color: var(--primary);
|
|
background: var(--primary-bg);
|
|
color: var(--primary);
|
|
transform: translateY(-2px);
|
|
box-shadow: var(--card-shadow-hover);
|
|
}
|
|
.quick-link-card:focus-visible {
|
|
outline: 2px solid var(--primary);
|
|
outline-offset: 2px;
|
|
}
|
|
.quick-link-icon {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: var(--border-radius);
|
|
background: var(--primary-bg);
|
|
color: var(--primary);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.quick-link-icon svg {
|
|
width: 24px;
|
|
height: 24px;
|
|
fill: currentColor;
|
|
}
|
|
.quick-link-info h4 {
|
|
font-size: 0.9375rem;
|
|
font-weight: 600;
|
|
color: var(--gray-800);
|
|
margin-bottom: 2px;
|
|
}
|
|
.quick-link-info p {
|
|
font-size: 0.8125rem;
|
|
color: var(--gray-500);
|
|
}
|
|
.quick-link-card:hover .quick-link-info h4 {
|
|
color: var(--primary);
|
|
}
|
|
.quick-links-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 16px;
|
|
}
|
|
@media (max-width: 1024px) {
|
|
.admin-stats-grid {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
}
|
|
@media (max-width: 768px) {
|
|
.admin-stats-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.quick-links-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.bar-chart {
|
|
gap: 16px;
|
|
}
|
|
.bar-chart-bar {
|
|
width: 40px;
|
|
}
|
|
}
|
|
</style>
|
|
<!-- PWA:start -->
|
|
<link rel="manifest" href="/manifest.webmanifest">
|
|
<meta name="theme-color" content="#0066CC">
|
|
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
|
|
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
|
|
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
|
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
|
|
<meta name="application-name" content="NIS2 Agile">
|
|
<script src="/js/pwa.js?v=20260614" defer></script>
|
|
<!-- PWA:end -->
|
|
</head>
|
|
<body>
|
|
<div class="app-layout">
|
|
<aside class="sidebar" id="sidebar"></aside>
|
|
|
|
<main class="main-content">
|
|
<header class="content-header">
|
|
<h1 class="h2">Pannello Amministrazione</h1>
|
|
<div class="content-header-actions">
|
|
<span class="badge badge-danger">Super Admin</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="content-body">
|
|
<!-- Stats Row 1 -->
|
|
<div class="admin-stats-grid" id="stats-row-1">
|
|
<div class="admin-stat-card">
|
|
<div class="admin-stat-icon blue" aria-hidden="true">
|
|
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-pa"></use></svg>
|
|
</div>
|
|
<div class="admin-stat-label" id="lbl-total-orgs">Totale Organizzazioni</div>
|
|
<div class="admin-stat-value" id="stat-total-orgs" aria-labelledby="lbl-total-orgs">--</div>
|
|
</div>
|
|
<div class="admin-stat-card">
|
|
<div class="admin-stat-icon green" aria-hidden="true">
|
|
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-check-circle"></use></svg>
|
|
</div>
|
|
<div class="admin-stat-label" id="lbl-active-orgs">Organizzazioni Attive</div>
|
|
<div class="admin-stat-value" id="stat-active-orgs" aria-labelledby="lbl-active-orgs">--</div>
|
|
</div>
|
|
<div class="admin-stat-card">
|
|
<div class="admin-stat-icon blue" aria-hidden="true">
|
|
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg>
|
|
</div>
|
|
<div class="admin-stat-label" id="lbl-total-users">Totale Utenti</div>
|
|
<div class="admin-stat-value" id="stat-total-users" aria-labelledby="lbl-total-users">--</div>
|
|
</div>
|
|
<div class="admin-stat-card">
|
|
<div class="admin-stat-icon green" aria-hidden="true">
|
|
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg>
|
|
</div>
|
|
<div class="admin-stat-label" id="lbl-active-users">Utenti Attivi</div>
|
|
<div class="admin-stat-value" id="stat-active-users" aria-labelledby="lbl-active-users">--</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats Row 2 -->
|
|
<div class="admin-stats-grid" id="stats-row-2">
|
|
<div class="admin-stat-card">
|
|
<div class="admin-stat-icon purple" aria-hidden="true">
|
|
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>
|
|
</div>
|
|
<div class="admin-stat-label" id="lbl-assessments">Assessment Completati</div>
|
|
<div class="admin-stat-value" id="stat-assessments" aria-labelledby="lbl-assessments">--</div>
|
|
</div>
|
|
<div class="admin-stat-card">
|
|
<div class="admin-stat-icon orange" aria-hidden="true">
|
|
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-warning-circle"></use></svg>
|
|
</div>
|
|
<div class="admin-stat-label" id="lbl-incidents">Incidenti Aperti</div>
|
|
<div class="admin-stat-value" id="stat-incidents" aria-labelledby="lbl-incidents">--</div>
|
|
</div>
|
|
<div class="admin-stat-card">
|
|
<div class="admin-stat-icon red" aria-hidden="true">
|
|
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-error"></use></svg>
|
|
</div>
|
|
<div class="admin-stat-label" id="lbl-risks">Totale Rischi</div>
|
|
<div class="admin-stat-value" id="stat-risks" aria-labelledby="lbl-risks">--</div>
|
|
</div>
|
|
<div class="admin-stat-card">
|
|
<div class="admin-stat-icon purple" aria-hidden="true">
|
|
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-software"></use></svg>
|
|
</div>
|
|
<div class="admin-stat-label" id="lbl-ai">Interazioni AI</div>
|
|
<div class="admin-stat-value" id="stat-ai" aria-labelledby="lbl-ai">--</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Plans Distribution -->
|
|
<div class="card mb-24">
|
|
<div class="card-header">
|
|
<h3>Distribuzione Piani</h3>
|
|
</div>
|
|
<div class="chart-container">
|
|
<div class="bar-chart" id="plans-chart" role="img" aria-label="Grafico a barre: distribuzione delle organizzazioni per piano di sottoscrizione">
|
|
<div class="spinner" style="margin:40px auto;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Links -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3>Gestione Piattaforma</h3>
|
|
</div>
|
|
<div class="card-body">
|
|
<nav class="quick-links-grid" aria-label="Collegamenti rapidi gestione piattaforma">
|
|
<a href="/admin/organizations.html" class="quick-link-card">
|
|
<div class="quick-link-icon" aria-hidden="true">
|
|
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-pa"></use></svg>
|
|
</div>
|
|
<div class="quick-link-info">
|
|
<h4>Gestisci Organizzazioni</h4>
|
|
<p>Visualizza e gestisci tutte le organizzazioni registrate sulla piattaforma.</p>
|
|
</div>
|
|
</a>
|
|
<a href="/admin/users.html" class="quick-link-card">
|
|
<div class="quick-link-icon" aria-hidden="true">
|
|
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg>
|
|
</div>
|
|
<div class="quick-link-info">
|
|
<h4>Gestisci Utenti</h4>
|
|
<p>Visualizza e gestisci tutti gli utenti registrati sulla piattaforma.</p>
|
|
</div>
|
|
</a>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<script src="/js/api.js?v=20260620"></script>
|
|
<script src="/js/common.js?v=20260624g"></script>
|
|
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
|
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
|
riusando tutti gli helper di common.js (zero backend). -->
|
|
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
|
<script>
|
|
if (window.bootstrap && bootstrap.loadFonts) {
|
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
|
}
|
|
</script>
|
|
<script src="/js/common-bi.js?v=20260624f"></script>
|
|
<script src="/js/i18n.js?v=20260620"></script>
|
|
<script>
|
|
// ── Auth check ───────────────────────────────────────────
|
|
if (!checkAuth()) throw new Error('Not authenticated');
|
|
loadSidebar();
|
|
I18n.init();
|
|
|
|
// ── Admin check ──────────────────────────────────────────
|
|
(async function checkAdmin() {
|
|
try {
|
|
const me = await api.getMe();
|
|
if (!me.success || !me.data || me.data.role !== 'super_admin') {
|
|
showNotification('Accesso non autorizzato. Solo gli amministratori possono accedere a questa pagina.', 'error');
|
|
setTimeout(() => { window.location.href = '/dashboard.html'; }, 1500);
|
|
return;
|
|
}
|
|
loadAdminStats();
|
|
} catch (e) {
|
|
window.location.href = '/dashboard.html';
|
|
}
|
|
})();
|
|
|
|
// ── Load Stats ───────────────────────────────────────────
|
|
async function loadAdminStats() {
|
|
try {
|
|
const result = await api.request('GET', '/admin/stats');
|
|
if (result.success && result.data) {
|
|
const d = result.data;
|
|
|
|
document.getElementById('stat-total-orgs').textContent = d.total_organizations ?? 0;
|
|
document.getElementById('stat-active-orgs').textContent = d.active_organizations ?? 0;
|
|
document.getElementById('stat-total-users').textContent = d.total_users ?? 0;
|
|
document.getElementById('stat-active-users').textContent = d.active_users ?? 0;
|
|
document.getElementById('stat-assessments').textContent = d.completed_assessments ?? 0;
|
|
document.getElementById('stat-incidents').textContent = d.open_incidents ?? 0;
|
|
document.getElementById('stat-risks').textContent = d.total_risks ?? 0;
|
|
document.getElementById('stat-ai').textContent = d.ai_interactions ?? 0;
|
|
|
|
renderPlansChart(d.plans_distribution || []);
|
|
} else {
|
|
showNotification('Impossibile caricare le statistiche.', 'error');
|
|
}
|
|
} catch (e) {
|
|
console.error('Errore caricamento stats:', e);
|
|
showNotification('Errore di connessione.', 'error');
|
|
}
|
|
}
|
|
|
|
// ── Plans Chart ──────────────────────────────────────────
|
|
function renderPlansChart(distribution) {
|
|
const container = document.getElementById('plans-chart');
|
|
|
|
const planLabels = { free: 'Free', professional: 'Professional', enterprise: 'Enterprise' };
|
|
const plans = { free: 0, professional: 0, enterprise: 0 };
|
|
|
|
distribution.forEach(p => {
|
|
const key = (p.subscription_plan || 'free').toLowerCase();
|
|
if (plans.hasOwnProperty(key)) {
|
|
plans[key] = parseInt(p.count) || 0;
|
|
}
|
|
});
|
|
|
|
const maxVal = Math.max(1, ...Object.values(plans));
|
|
|
|
let html = '';
|
|
for (const [key, count] of Object.entries(plans)) {
|
|
const heightPct = (count / maxVal) * 100;
|
|
html += `
|
|
<div class="bar-chart-item">
|
|
<div class="bar-chart-value">${count}</div>
|
|
<div class="bar-chart-bar ${key}" style="height:${Math.max(heightPct, 3)}%;"></div>
|
|
<div class="bar-chart-label">${planLabels[key]}</div>
|
|
</div>`;
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|