patchcenter/app/templates/qualys_deploy.html

434 lines
22 KiB
HTML

{% extends 'base.html' %}
{% block title %}Déploiement Agent Qualys{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Déploiement Agent Qualys</h2>
<p class="text-xs text-gray-500 mt-1">Installer et vérifier l'agent Qualys Cloud sur les serveurs</p>
</div>
<a href="/qualys/agents" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">Agents</a>
</div>
{% if msg == 'no_servers' %}
<div style="background:#5a1a1a;color:#ff3366;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
Sélectionner au moins un serveur.
</div>
{% endif %}
<div x-data="{selectedIds: [], selectAll: false, filter: '', filterDom: '', filterEnv: '', filterOs: ''}" class="space-y-4">
<!-- Configuration -->
<div class="card p-4">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Configuration de l'agent</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label class="text-xs text-gray-500 block mb-1">ActivationId Linux {% if keys_linux %}<span class="text-cyber-green">({{ keys_linux|length }} keys via API)</span>{% endif %}</label>
{% if keys_linux %}
<select id="activation_id_linux" class="w-full text-xs" style="font-family:monospace;-webkit-text-security:disc;text-security:disc" onfocus="this.style.webkitTextSecurity='none';this.style.textSecurity='none'" onblur="this.style.webkitTextSecurity='disc';this.style.textSecurity='disc'">
{% for k in keys_linux %}<option value="{{ k.key }}" {% if k.key == activation_id_linux %}selected{% endif %}>{{ k.key }} — {{ k.title or '' }} ({{ k.used }} uses)</option>{% endfor %}
</select>
{% else %}
<input type="password" id="activation_id_linux" value="{{ activation_id_linux }}" class="w-full text-xs" style="font-family:monospace">
{% endif %}
<input type="hidden" id="activation_id" value="{{ activation_id_linux }}">
<input type="hidden" id="activation_id_windows" value="{{ activation_id_windows }}">
</div>
<div>
<label class="text-xs text-gray-500 block mb-1">CustomerId <button type="button" onclick="toggleField('customer_id')" class="text-xs text-cyber-accent ml-1">👁</button></label>
<input type="password" id="customer_id" value="{{ customer_id }}" class="w-full text-xs" style="font-family:monospace">
</div>
<div>
<label class="text-xs text-gray-500 block mb-1">ServerUri</label>
<input type="text" id="server_uri" value="{{ server_uri }}" class="w-full text-xs" style="font-family:monospace">
</div>
</div>
<div class="mt-3 flex gap-2 items-center">
<button type="button" onclick="saveSettings()" class="btn-sm bg-cyber-green text-black px-3 py-1">💾 Sauvegarder ces valeurs</button>
<span id="save-msg" class="text-xs"></span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<div>
<label class="text-xs text-gray-500 block mb-1">Package DEB (Debian/Ubuntu)</label>
<select id="package_deb" class="w-full text-xs">
{% for p in packages.deb %}
<option value="{{ p.path }}">v{{ p.version }} — {{ p.name }} ({{ p.size }} Mo)</option>
{% endfor %}
{% if not packages.deb %}<option value="">Aucun package .deb</option>{% endif %}
</select>
</div>
<div>
<label class="text-xs text-gray-500 block mb-1">Package RPM (RHEL/CentOS)</label>
<select id="package_rpm" class="w-full text-xs">
{% for p in packages.rpm %}
<option value="{{ p.path }}">v{{ p.version }} — {{ p.name }} ({{ p.size }} Mo)</option>
{% endfor %}
{% if not packages.rpm %}<option value="">Aucun package .rpm</option>{% endif %}
</select>
</div>
</div>
</div>
<!-- Filtres -->
<div class="card p-3" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" x-model="filter" placeholder="Rechercher hostname..." class="text-xs" style="width:200px">
<select x-model="filterDom" class="text-xs" style="width:150px">
<option value="">Tous domaines</option>
{% set doms = servers|map(attribute='domain')|select('string')|unique|sort %}
{% for d in doms %}{% if d %}<option>{{ d }}</option>{% endif %}{% endfor %}
</select>
<select x-model="filterEnv" class="text-xs" style="width:150px">
<option value="">Tous envs</option>
{% set envs = servers|map(attribute='env')|select('string')|unique|sort %}
{% for e in envs %}{% if e %}<option>{{ e }}</option>{% endif %}{% endfor %}
</select>
<span class="text-xs text-gray-500" x-text="selectedIds.length + ' sélectionné(s)'"></span>
<span class="text-xs text-cyber-yellow ml-auto">Linux uniquement</span>
</div>
<!-- Actions -->
<div style="display:flex;gap:8px;align-items:center">
<button id="btn-deploy" class="btn-primary px-4 py-2 text-sm"
:disabled="selectedIds.length === 0"
@click="deployAgent(selectedIds)">
Déployer l'agent
</button>
<button id="btn-check" style="padding:8px 16px;font-size:0.85rem;background:#334155;color:#e2e8f0;border:1px solid #475569;border-radius:6px;cursor:pointer"
:disabled="selectedIds.length === 0"
@click="checkAgent(selectedIds)">
Vérifier l'agent
</button>
<label class="text-xs text-gray-400 ml-4" style="display:flex;align-items:center;gap:4px">
<input type="checkbox" id="force_downgrade"> Forcer le downgrade
</label>
</div>
<!-- Serveurs -->
<div class="card overflow-hidden">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2 w-8"><input type="checkbox" @change="selectAll = $event.target.checked; selectedIds = selectAll ? servers.map(s => s.id) : []"
x-init="servers = {{ servers | tojson }}"></th>
<th class="p-2 text-left">Hostname</th>
<th class="p-2">OS / Version</th>
<th class="p-2">Domaine</th>
<th class="p-2">Env</th>
<th class="p-2">État</th>
<th class="p-2">Agent installé</th>
<th class="p-2">SSH</th>
</tr></thead>
<tbody>
{% for s in servers %}
<tr x-show="
(filter === '' || '{{ s.hostname }}'.toLowerCase().includes(filter.toLowerCase()))
&& (filterDom === '' || '{{ s.domain or '' }}' === filterDom)
&& (filterEnv === '' || '{{ s.env or '' }}' === filterEnv)
&& (filterOs === '' || '{{ s.os_family or '' }}' === filterOs)
" class="border-t border-cyber-border/30 hover:bg-cyber-hover">
<td class="p-2 text-center"><input type="checkbox" :value="{{ s.id }}"
@change="$event.target.checked ? selectedIds.push({{ s.id }}) : selectedIds = selectedIds.filter(x => x !== {{ s.id }})"></td>
<td class="p-2 font-mono">{{ s.hostname }}</td>
<td class="p-2 text-center">
{% if s.os_family == 'linux' %}<span class="badge badge-green">Linux</span>
{% else %}<span class="badge badge-blue">{{ s.os_family or '?' }}</span>{% endif %}
{% if s.os_version %}<div class="text-gray-400 mt-1" style="font-size:10px">{{ s.os_version }}</div>{% endif %}
</td>
<td class="p-2 text-center text-gray-400">{{ s.domain or '-' }}</td>
<td class="p-2 text-center">{{ s.env or '-' }}</td>
<td class="p-2 text-center">
{% if s.etat == 'Production' %}<span class="badge badge-green">Prod</span>
{% else %}{{ s.etat or '-' }}{% endif %}
</td>
<td class="p-2 text-center">
{% if s.agent_version %}
<span class="font-mono text-cyber-green">{{ s.agent_version }}</span>
<span class="ml-1 badge {% if s.agent_status and 'ACTIVE' in (s.agent_status|upper) and 'INACTIVE' not in (s.agent_status|upper) %}badge-green{% elif s.agent_status and 'INACTIVE' in (s.agent_status|upper) %}badge-red{% else %}badge-gray{% endif %}" style="font-size:9px">{{ s.agent_status or '?' }}</span>
{% else %}<span class="text-gray-600"></span>{% endif %}
</td>
<td class="p-2 text-center text-gray-500">{{ s.ssh_user or 'root' }}:{{ s.ssh_port or 22 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Zone progression / résultats -->
<div id="progress-zone" style="display:none" class="mt-4 space-y-4">
<div class="card p-4">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-bold text-cyber-accent" id="progress-title">Déploiement en cours...</h3>
<span class="text-xs text-gray-400 font-mono" id="progress-timer">0s</span>
</div>
<!-- Barre de progression globale -->
<div style="background:#1e293b;border-radius:4px;height:8px;margin-bottom:16px;overflow:hidden">
<div id="progress-bar" style="height:100%;background:#00ffc8;width:0%;transition:width 0.5s ease"></div>
</div>
<div class="text-xs text-gray-400 mb-3" id="progress-summary"></div>
<!-- Tableau progression par serveur -->
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2 text-left">Hostname</th>
<th class="p-2">Étape</th>
<th class="p-2 text-left">Détail</th>
</tr></thead>
<tbody id="progress-body"></tbody>
</table>
</div>
<!-- Log détaillé (affiché une fois terminé) -->
<div id="progress-log" class="card p-4" style="display:none">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Log détaillé</h3>
<div id="progress-log-content" style="background:#0a0a23;border-radius:6px;padding:12px;max-height:400px;overflow-y:auto;font-family:monospace;font-size:0.75rem;color:#00ff88;white-space:pre-wrap"></div>
</div>
</div>
<!-- Overlay vérification (check reste synchrone, c'est rapide) -->
<div id="check-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;justify-content:center;align-items:center">
<div class="card p-6 text-center" style="min-width:360px">
<div style="margin-bottom:12px">
<svg style="display:inline;animation:opspin 1s linear infinite;width:40px;height:40px" viewBox="0 0 24 24" fill="none" stroke="#00ffc8" stroke-width="2"><circle cx="12" cy="12" r="10" stroke-opacity="0.3"/><path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round"/></svg>
</div>
<div class="text-cyber-accent font-bold text-sm" id="check-title">Vérification en cours...</div>
<div class="text-gray-400 text-xs mt-2" id="check-detail"></div>
<div class="text-gray-500 text-xs mt-3 font-mono" id="check-timer">0s</div>
</div>
</div>
<style>@keyframes opspin{to{transform:rotate(360deg)}}</style>
<!-- Zone résultats check -->
<div id="check-results" style="display:none" class="mt-4 space-y-4">
<div id="check-kpis" style="display:flex;gap:8px;margin-bottom:16px"></div>
<div id="check-table" class="card overflow-hidden"></div>
</div>
<script>
function toggleField(id) {
var el = document.getElementById(id);
if (!el) return;
el.type = el.type === 'password' ? 'text' : 'password';
}
function saveSettings() {
var msg = document.getElementById('save-msg');
var actLin = (document.getElementById('activation_id_linux') || {}).value || '';
var actWin = (document.getElementById('activation_id_windows') || {}).value || '';
var cust = (document.getElementById('customer_id') || {}).value || '';
var uri = (document.getElementById('server_uri') || {}).value || '';
msg.textContent = 'Sauvegarde...';
msg.style.color = '#888';
fetch('/qualys/deploy/save-settings', {
method:'POST',
headers:{'Content-Type':'application/json'},
credentials:'same-origin',
body: JSON.stringify({
qualys_activation_id_linux: actLin,
qualys_activation_id_windows: actWin,
qualys_customer_id: cust,
qualys_server_uri: uri
})
})
.then(function(r){return r.json();})
.then(function(d){
if (d.ok) { msg.textContent = '✓ ' + d.msg; msg.style.color = '#8f8'; }
else { msg.textContent = 'Erreur: ' + (d.msg || 'inconnue'); msg.style.color = '#f88'; }
})
.catch(function(e){
msg.textContent = 'Erreur reseau: ' + e.message; msg.style.color = '#f88';
});
}
var _pollTimer = null;
var _checkTimer = null;
var STAGE_LABELS = {
'pending': {label: 'En attente', cls: 'badge-gray', icon: '&#9679;'},
'connecting': {label: 'Connexion SSH', cls: 'badge-yellow', icon: '&#8635;'},
'checking': {label: 'Vérification', cls: 'badge-yellow', icon: '&#8635;'},
'copying': {label: 'Copie package', cls: 'badge-yellow', icon: '&#8635;'},
'installing': {label: 'Installation', cls: 'badge-yellow', icon: '&#8635;'},
'activating': {label: 'Activation', cls: 'badge-yellow', icon: '&#8635;'},
'restarting': {label: 'Redémarrage', cls: 'badge-yellow', icon: '&#8635;'},
'verifying': {label: 'Vérification', cls: 'badge-yellow', icon: '&#8635;'},
'success': {label: 'Succès', cls: 'badge-green', icon: '&#10003;'},
'already_installed': {label: 'Déjà installé', cls: 'badge-blue', icon: '&#10003;'},
'downgrade_refused': {label: 'Downgrade refusé', cls: 'badge-yellow', icon: '&#9888;'},
'partial': {label: 'Partiel', cls: 'badge-yellow', icon: '&#9888;'},
'failed': {label: 'Échec', cls: 'badge-red', icon: '&#10007;'},
};
function _stageBadge(stage) {
var s = STAGE_LABELS[stage] || {label: stage, cls: 'badge-gray', icon: '?'};
var anim = (stage !== 'pending' && stage !== 'success' && stage !== 'failed' && stage !== 'partial' && stage !== 'already_installed')
? ' style="animation:pulse 1.5s ease-in-out infinite"' : '';
return '<span class="badge ' + s.cls + '"' + anim + '>' + s.icon + ' ' + s.label + '</span>';
}
function deployAgent(ids) {
if (!ids.length) return;
if (!confirm('Déployer l\'agent sur ' + ids.length + ' serveur(s) ?\n\nLe déploiement se fait en arrière-plan.')) return;
document.getElementById('btn-deploy').disabled = true;
fetch('/qualys/deploy/run', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
server_ids: ids.join(','),
activation_id: document.getElementById('activation_id').value,
customer_id: document.getElementById('customer_id').value,
server_uri: document.getElementById('server_uri').value,
package_deb: document.getElementById('package_deb').value,
package_rpm: document.getElementById('package_rpm').value,
force_downgrade: document.getElementById('force_downgrade').checked,
})
})
.then(function(r){
var ct = r.headers.get('content-type') || '';
if (ct.indexOf('json') === -1) throw new Error('Erreur serveur (HTTP ' + r.status + ')');
return r.json();
})
.then(function(data){
if (data.ok && data.job_id) {
startPolling(data.job_id, data.total);
} else {
alert('Erreur: ' + (data.msg || 'Erreur inconnue'));
document.getElementById('btn-deploy').disabled = false;
}
})
.catch(function(err){
alert('Erreur: ' + err.message);
document.getElementById('btn-deploy').disabled = false;
});
}
function startPolling(jobId, total) {
var zone = document.getElementById('progress-zone');
var title = document.getElementById('progress-title');
var timer = document.getElementById('progress-timer');
var bar = document.getElementById('progress-bar');
var summary = document.getElementById('progress-summary');
var tbody = document.getElementById('progress-body');
zone.style.display = 'block';
zone.scrollIntoView({behavior: 'smooth'});
title.textContent = 'Déploiement en cours... (' + total + ' serveur(s))';
function poll() {
fetch('/qualys/deploy/status/' + jobId, {credentials: 'same-origin'})
.then(function(r){ return r.json(); })
.then(function(data){
if (!data.ok) return;
timer.textContent = data.elapsed + 's';
var pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0;
bar.style.width = pct + '%';
summary.textContent = data.done + ' / ' + data.total + ' terminé(s)';
// Build rows
var rows = '';
var hostnames = Object.keys(data.servers).sort();
hostnames.forEach(function(hn){
var s = data.servers[hn];
rows += '<tr class="border-t border-cyber-border/30">';
rows += '<td class="p-2 font-mono">' + hn + '</td>';
rows += '<td class="p-2 text-center">' + _stageBadge(s.stage) + '</td>';
rows += '<td class="p-2 text-gray-400 text-xs">' + (s.detail || '') + '</td>';
rows += '</tr>';
});
tbody.innerHTML = rows;
if (data.finished) {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
var ok = 0, fail = 0;
hostnames.forEach(function(hn){
var st = data.servers[hn].stage;
if (st === 'success' || st === 'already_installed') ok++;
else if (st === 'failed') fail++;
});
title.innerHTML = 'Déploiement terminé — <span class="text-cyber-green">' + ok + ' OK</span> / <span class="text-cyber-red">' + fail + ' échec(s)</span>';
bar.style.background = fail > 0 ? '#ff3366' : '#00ffc8';
document.getElementById('btn-deploy').disabled = false;
// Show log
if (data.log && data.log.length > 0) {
document.getElementById('progress-log-content').textContent = data.log.join('\n');
document.getElementById('progress-log').style.display = 'block';
}
}
})
.catch(function(){});
}
poll();
_pollTimer = setInterval(poll, 2000);
}
// === Check (reste synchrone, c'est rapide ~5-15s par serveur) ===
function checkAgent(ids) {
if (!ids.length) return;
var ov = document.getElementById('check-overlay');
var timer = document.getElementById('check-timer');
document.getElementById('check-title').textContent = 'Vérification de ' + ids.length + ' serveur(s)...';
document.getElementById('check-detail').textContent = 'Connexion SSH et vérification du statut';
timer.textContent = '0s';
ov.style.display = 'flex';
var t0 = Date.now();
if (_checkTimer) clearInterval(_checkTimer);
_checkTimer = setInterval(function(){ timer.textContent = Math.floor((Date.now()-t0)/1000) + 's'; }, 1000);
fetch('/qualys/deploy/check', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({server_ids: ids.join(',')})
})
.then(function(r){
var ct = r.headers.get('content-type') || '';
if (ct.indexOf('json') === -1) throw new Error('Erreur serveur (HTTP ' + r.status + ')');
return r.json();
})
.then(function(data){
clearInterval(_checkTimer);
ov.style.display = 'none';
if (data.ok) showCheckResults(data);
else alert('Erreur: ' + (data.msg || ''));
})
.catch(function(err){
clearInterval(_checkTimer);
ov.style.display = 'none';
alert('Erreur: ' + err.message);
});
}
function showCheckResults(data) {
var zone = document.getElementById('check-results');
var kpis = document.getElementById('check-kpis');
var tbl = document.getElementById('check-table');
kpis.innerHTML =
'<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-accent">' + data.total + '</div><div class="text-xs text-gray-500">Total</div></div>' +
'<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-green">' + (data.active||0) + '</div><div class="text-xs text-gray-500">Actifs</div></div>' +
'<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-yellow">' + (data.not_installed||0) + '</div><div class="text-xs text-gray-500">Non installés</div></div>' +
'<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-red">' + (data.failed||0) + '</div><div class="text-xs text-gray-500">Connexion échouée</div></div>';
var rows = '';
data.results.forEach(function(r){
var cls = r.status==='ACTIVE'?'badge-green': r.status==='NOT_INSTALLED'?'badge-yellow': r.status==='INACTIVE'?'badge-yellow': 'badge-red';
rows += '<tr class="border-t border-cyber-border/30">';
rows += '<td class="p-2 font-mono">' + r.hostname + '</td>';
rows += '<td class="p-2 text-center"><span class="badge ' + cls + '">' + r.status + '</span></td>';
rows += '<td class="p-2 text-gray-400">' + (r.detail||'') + '</td>';
rows += '<td class="p-2 text-center font-mono text-gray-400">' + (r.version||'-') + '</td>';
rows += '<td class="p-2 text-center">' + (r.service_status||'-') + '</td>';
rows += '</tr>';
});
tbl.innerHTML = '<table class="w-full table-cyber text-xs"><thead><tr><th class="p-2 text-left">Hostname</th><th class="p-2">Statut</th><th class="p-2 text-left">Détail</th><th class="p-2">Version</th><th class="p-2">Service</th></tr></thead><tbody>' + rows + '</tbody></table>';
zone.style.display = 'block';
zone.scrollIntoView({behavior:'smooth'});
}
</script>
<style>
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }
</style>
{% endblock %}