Files
Server_Monitorizare_v2/templates/ansible/execute.html
2026-04-23 15:55:46 +03:00

613 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Execute Ansible Playbook - Server Monitoring{% endblock %}
{% block page_title %}Execute Ansible Playbook{% endblock %}
{% block extra_css %}
<style>
.playbook-card {
border: 2px solid #e9ecef; border-radius: 8px;
transition: all 0.2s; cursor: pointer;
}
.playbook-card:hover { border-color: #0d6efd; box-shadow: 0 3px 10px rgba(13,110,253,.15); }
.playbook-card.selected { border-color: #198754; background-color: #f8fff9; }
.json-editor { font-family: 'Courier New', monospace; background-color: #f8f9fa; }
#liveCard { display: none; }
#liveTerminal {
background: #1e1e1e; color: #d4d4d4;
font-family: 'Courier New', monospace; font-size: .78rem;
line-height: 1.5; height: 380px; overflow-y: auto;
white-space: pre-wrap; word-break: break-all;
border-radius: 0 0 8px 8px; padding: 12px 16px;
}
#liveTerminal .ansi-ok { color: #4ec9b0; }
#liveTerminal .ansi-changed { color: #dcdcaa; }
#liveTerminal .ansi-fail { color: #f44747; }
#liveTerminal .ansi-unreachable { color: #ce9178; }
#liveTerminal .ansi-task { color: #9cdcfe; font-weight: bold; }
#liveTerminal .ansi-play { color: #c586c0; font-weight: bold; }
.live-header {
background: #252526; color: #ccc; border-radius: 8px 8px 0 0;
padding: 8px 16px; font-size: .8rem; display: flex; align-items: center; gap: 10px;
}
.pulse-dot {
width: 10px; height: 10px; border-radius: 50%;
background: #4ec9b0; animation: pulse 1.2s infinite; display: inline-block;
}
.pulse-dot.done { background: #6a9955; animation: none; }
.pulse-dot.error { background: #f44747; animation: none; }
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:.3;} }
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category=='error' else category }} alert-dismissible fade show">
{{ message }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}{% endif %}
{% endwith %}
<form method="POST" id="executeForm">
<div class="row g-3 mb-3">
<!-- ── Playbook selection ──────────────────────────────────── -->
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="fas fa-book me-2"></i>Select Playbook</h5>
</div>
<div class="card-body" style="overflow-y:auto; max-height:480px;">
<h6 class="text-muted fw-bold mb-2 small text-uppercase">Built-in</h6>
<div class="card playbook-card mb-2" data-name="update_devices" onclick="selectPlaybook('update_devices')">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1 text-primary"><i class="fas fa-download me-1"></i>Update Devices</h6>
<p class="small text-muted mb-0">Update all packages on monitoring devices</p>
</div>
<span class="badge bg-success ms-2 flex-shrink-0">Built-in</span>
</div>
</div>
</div>
<div class="card playbook-card mb-2" data-name="restart_service" onclick="selectPlaybook('restart_service')">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1 text-success"><i class="fas fa-redo me-1"></i>Restart Service</h6>
<p class="small text-muted mb-0">Restart monitoring services on devices</p>
</div>
<span class="badge bg-success ms-2 flex-shrink-0">Built-in</span>
</div>
</div>
</div>
<div class="card playbook-card mb-3" data-name="system_health" onclick="selectPlaybook('system_health')">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1 text-info"><i class="fas fa-heartbeat me-1"></i>System Health</h6>
<p class="small text-muted mb-0">Check system health and monitoring status</p>
</div>
<span class="badge bg-success ms-2 flex-shrink-0">Built-in</span>
</div>
</div>
</div>
<h6 class="text-muted fw-bold mb-2 small text-uppercase">Custom Playbooks</h6>
<select class="form-select" id="customPlaybook">
<option value="">— select custom playbook —</option>
</select>
<input type="hidden" name="playbook" id="selectedPlaybook">
</div>
</div>
</div>
<!-- ── Target selection ────────────────────────────────────── -->
<div class="col-lg-7">
<div class="card h-100">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-crosshairs me-2"></i>Select Target</h5>
<span id="targetCountBadge" class="badge bg-light text-dark"></span>
</div>
<div class="card-body p-3">
<!-- Mode pills -->
<ul class="nav nav-pills nav-fill mb-3">
<li class="nav-item">
<button type="button" class="nav-link active" id="pill-all" onclick="setTargetMode('all')">
<i class="fas fa-globe me-1"></i>All Hosts
</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link" id="pill-group" onclick="setTargetMode('group')">
<i class="fas fa-layer-group me-1"></i>By Group
</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link" id="pill-host" onclick="setTargetMode('host')">
<i class="fas fa-server me-1"></i>By Host
</button>
</li>
</ul>
<!-- All Hosts panel -->
<div id="panel-all">
{% set ns = namespace(total=0) %}
{% for g in inventory.groups.values() %}{% set ns.total = ns.total + g.hosts|length %}{% endfor %}
{% if ns.total > 0 %}
<div class="alert alert-info mb-0">
<i class="fas fa-info-circle me-2"></i>
Will run on <strong>all {{ ns.total }} host(s)</strong>
across <strong>{{ inventory.groups|length }} group(s)</strong>.
</div>
{% else %}
<div class="alert alert-warning mb-0">
<i class="fas fa-exclamation-triangle me-2"></i>
No hosts in inventory yet.
<a href="{{ url_for('ansible_web.devices') }}">Add devices to inventory</a> first.
</div>
{% endif %}
</div>
<!-- By Group panel -->
<div id="panel-group" class="d-none" style="max-height:300px;overflow-y:auto;">
{% if inventory.groups %}
{% for group_name, group in inventory.groups.items() %}
<div class="form-check border rounded p-2 mb-2">
<input class="form-check-input group-cb" type="checkbox"
id="grp-{{ group_name }}" value="{{ group_name }}"
data-count="{{ group.hosts|length }}"
onchange="updateTargetCount()">
<label class="form-check-label w-100" for="grp-{{ group_name }}">
<div class="d-flex justify-content-between align-items-center">
<strong>{{ group_name }}</strong>
<span class="badge bg-secondary">{{ group.hosts|length }} host(s)</span>
</div>
<small class="text-muted">
{% for h in group.hosts[:3] %}{{ h.hostname }}{% if not loop.last %}, {% endif %}{% endfor %}{% if group.hosts|length > 3 %}&nbsp;+{{ group.hosts|length - 3 }} more{% endif %}
</small>
</label>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center py-3">No groups in inventory.</p>
{% endif %}
</div>
<!-- By Host panel -->
<div id="panel-host" class="d-none" style="max-height:300px;overflow-y:auto;">
{% if all_inv_hosts %}
<div class="mb-2 d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="checkAllHosts(true)">
<i class="fas fa-check-double me-1"></i>Select All
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="checkAllHosts(false)">Clear</button>
</div>
{% for h in all_inv_hosts %}
<div class="form-check border-bottom py-2">
<input class="form-check-input host-cb" type="checkbox"
id="invhost-{{ h.hostname }}" value="{{ h.hostname }}"
onchange="updateTargetCount()">
<label class="form-check-label d-flex justify-content-between w-100" for="invhost-{{ h.hostname }}">
<strong>{{ h.hostname }}</strong>
<code class="text-muted" style="font-size:.8rem;">{{ h.ip }}</code>
</label>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center py-3">No hosts in inventory yet.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div><!-- /top row -->
<!-- ── Advanced Options ──────────────────────────────────────── -->
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-header bg-info text-white">
<h6 class="mb-0">
<i class="fas fa-cogs me-1"></i>Advanced Options
<button type="button" class="btn btn-outline-light btn-sm ms-2"
data-bs-toggle="collapse" data-bs-target="#advancedOptions">
<i class="fas fa-chevron-down"></i>
</button>
</h6>
</div>
<div class="collapse" id="advancedOptions">
<div class="card-body">
<div class="row">
<div class="col-md-4">
<h6>Execution Settings</h6>
<div class="mb-3">
<label class="form-label">Priority</label>
<select class="form-select" name="priority">
<option value="1">1 — Low</option>
<option value="3">3 — Below Normal</option>
<option value="5" selected>5 — Normal</option>
<option value="7">7 — High</option>
<option value="10">10 — Critical</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Max Retries</label>
<select class="form-select" name="max_retries">
<option value="0" selected>0 — No retries</option>
<option value="1">1 retry</option>
<option value="2">2 retries</option>
<option value="3">3 retries</option>
</select>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="checkMode" name="check_mode">
<label class="form-check-label" for="checkMode">Dry run (check mode)</label>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Extra Variables (JSON)</label>
<textarea class="form-control json-editor" name="extra_vars" id="extraVars" rows="6"
placeholder='{"variable": "value"}'></textarea>
<div class="form-text">Optional variables passed to playbook</div>
</div>
<div class="col-md-4">
<h6>Common Variables</h6>
<ul class="list-unstyled small">
<li><code>timeout: 600</code></li>
<li><code>reboot_timeout: 900</code></li>
<li><code>become_user: "root"</code></li>
<li><code>force: true</code></li>
</ul>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="addCommonVars()">
<i class="fas fa-plus me-1"></i>Add Common Vars
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── Summary bar + Execute button ─────────────────────────── -->
<div class="card mb-3">
<div class="card-body py-3">
<div class="row align-items-center">
<div class="col-md-3">
<small class="text-muted d-block">Playbook</small>
<strong id="summaryPlaybook">None selected</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">Target</small>
<strong id="summaryDevices">None selected</strong>
</div>
<div class="col-md-3">
<small class="text-muted d-block">Est. Duration</small>
<strong id="estimatedDuration">Unknown</strong>
</div>
<div class="col-md-2 text-end">
<button type="submit" class="btn btn-primary btn-lg w-100" id="executeButton">
<i class="fas fa-play me-1"></i>Execute
</button>
</div>
</div>
</div>
</div>
<!-- Hidden host inputs populated by JS before submit -->
<div id="hostsContainer"></div>
</form>
<!-- ── Live Execution Output ──────────────────────────────────── -->
<div class="row mt-3" id="liveCard">
<div class="col-12">
<div class="card shadow">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<span class="fw-semibold">
<i class="fas fa-terminal me-2"></i>Live Execution Output
</span>
<div class="d-flex align-items-center gap-3">
<span id="liveStatusBadge" class="badge bg-secondary">Waiting…</span>
<a id="liveDetailsLink" href="#" class="btn btn-sm btn-outline-primary" style="display:none">
<i class="fas fa-external-link-alt me-1"></i>Full Details
</a>
</div>
</div>
<div class="live-header">
<span class="pulse-dot" id="livePulseDot"></span>
<span id="liveStatusText">Initializing…</span>
<span class="ms-auto text-muted" id="liveElapsed"></span>
</div>
<div id="liveTerminal"></div>
<div class="card-footer d-flex justify-content-between align-items-center py-2">
<small class="text-muted" id="liveHostSummary"></small>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" onclick="toggleAutoScroll()">
<i class="fas fa-arrow-down" id="autoScrollIcon"></i> Auto-scroll
</button>
<button class="btn btn-sm btn-outline-danger" id="liveStopBtn" onclick="stopPolling()">
<i class="fas fa-stop"></i> Stop polling
</button>
</div>
</div>
</div>
</div>
</div>
</div><!-- /container-fluid -->
<script>
// ── State ────────────────────────────────────────────────────────────
let selectedPlaybook = null;
let targetMode = 'all';
// Live output state
let pollTimer = null;
let executionId = null;
let autoScroll = true;
let pollStartTime = null;
// ── Playbook selection ───────────────────────────────────────────────
function selectPlaybook(name) {
document.querySelectorAll('.playbook-card').forEach(c => c.classList.remove('selected'));
document.getElementById('customPlaybook').value = '';
document.querySelector(`.playbook-card[data-name="${name}"]`)?.classList.add('selected');
selectedPlaybook = name;
document.getElementById('selectedPlaybook').value = name;
updateSummary();
}
document.getElementById('customPlaybook').addEventListener('change', function() {
if (this.value) {
document.querySelectorAll('.playbook-card').forEach(c => c.classList.remove('selected'));
selectedPlaybook = this.value;
document.getElementById('selectedPlaybook').value = this.value;
updateSummary();
}
});
// ── Target mode ──────────────────────────────────────────────────────
function setTargetMode(mode) {
targetMode = mode;
['all','group','host'].forEach(m => {
document.getElementById(`panel-${m}`).classList.toggle('d-none', m !== mode);
document.getElementById(`pill-${m}`).classList.toggle('active', m === mode);
});
updateTargetCount();
}
function updateTargetCount() {
let count = 0, label = '';
if (targetMode === 'all') {
count = document.querySelectorAll('.host-cb').length;
label = count > 0 ? `All — ${count} host(s)` : 'No hosts in inventory';
} else if (targetMode === 'group') {
const grps = document.querySelectorAll('.group-cb:checked');
grps.forEach(cb => count += parseInt(cb.dataset.count || '0'));
label = grps.length > 0 ? `${grps.length} group(s) / ${count} host(s)` : 'None selected';
} else {
count = document.querySelectorAll('.host-cb:checked').length;
label = count > 0 ? `${count} host(s) selected` : 'None selected';
}
document.getElementById('targetCountBadge').textContent = label;
updateSummary();
}
function checkAllHosts(check) {
document.querySelectorAll('.host-cb').forEach(cb => cb.checked = check);
updateTargetCount();
}
function getTargetCount() {
if (targetMode === 'all') return document.querySelectorAll('.host-cb').length;
if (targetMode === 'group') {
let c = 0;
document.querySelectorAll('.group-cb:checked').forEach(cb => c += parseInt(cb.dataset.count || '0'));
return c;
}
return document.querySelectorAll('.host-cb:checked').length;
}
// Populate hidden <input name="hosts"> elements before submit
function buildHiddenInputs() {
const container = document.getElementById('hostsContainer');
container.innerHTML = '';
const add = v => {
const inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'hosts'; inp.value = v;
container.appendChild(inp);
};
if (targetMode === 'all') {
document.querySelectorAll('.host-cb').forEach(cb => add(cb.value));
if (container.children.length === 0) add('all'); // fallback if inventory empty
} else if (targetMode === 'group') {
document.querySelectorAll('.group-cb:checked').forEach(cb => add(cb.value));
} else {
document.querySelectorAll('.host-cb:checked').forEach(cb => add(cb.value));
}
}
// ── Summary bar ──────────────────────────────────────────────────────
function updateSummary() {
document.getElementById('summaryPlaybook').textContent = selectedPlaybook || 'None selected';
const count = getTargetCount();
const sd = document.getElementById('summaryDevices');
if (targetMode === 'all') {
sd.textContent = count > 0 ? `All hosts (${count})` : 'No hosts in inventory';
} else if (targetMode === 'group') {
const g = document.querySelectorAll('.group-cb:checked').length;
sd.textContent = g > 0 ? `${g} group(s), ~${count} host(s)` : 'None selected';
} else {
sd.textContent = count > 0 ? `${count} host(s) selected` : 'None selected';
}
const dur = document.getElementById('estimatedDuration');
if (selectedPlaybook && count > 0) {
if (selectedPlaybook === 'update_devices')
dur.textContent = `${Math.max(5, count * 2)}${count * 10} min`;
else if (selectedPlaybook === 'restart_service')
dur.textContent = `${count}${count * 2} min`;
else dur.textContent = 'Varies';
} else { dur.textContent = 'Unknown'; }
}
function addCommonVars() {
const el = document.getElementById('extraVars');
const common = {"timeout": 600, "reboot_timeout": 900, "become_user": "root"};
try {
let cur = el.value.trim() ? JSON.parse(el.value) : {};
Object.assign(cur, common);
el.value = JSON.stringify(cur, null, 2);
} catch { el.value = JSON.stringify(common, null, 2); }
}
// ── Form submission ──────────────────────────────────────────────────
document.getElementById('executeForm').addEventListener('submit', function(e) {
e.preventDefault();
if (!selectedPlaybook) { alert('Please select a playbook'); return; }
const count = getTargetCount();
if (count === 0) { alert('Please select at least one host or group'); return; }
const extraRaw = document.getElementById('extraVars').value.trim();
if (extraRaw) {
try { JSON.parse(extraRaw); }
catch { alert('Invalid JSON in Extra Variables'); return; }
}
const grpCount = document.querySelectorAll('.group-cb:checked').length;
const targetDesc = targetMode === 'all' ? `all hosts (${count})` :
targetMode === 'group' ? `${grpCount} group(s)` :
`${count} host(s)`;
if (!confirm(`Execute "${selectedPlaybook}" on ${targetDesc}?\nThis cannot be undone.`)) return;
buildHiddenInputs();
const btn = document.getElementById('executeButton');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting…';
resetLiveCard();
document.getElementById('liveCard').style.display = '';
fetch(this.action, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: new FormData(this),
})
.then(r => r.json())
.then(data => {
if (data.success) {
executionId = data.execution_id;
pollStartTime = Date.now();
const link = document.getElementById('liveDetailsLink');
link.href = `/ansible/executions/${executionId}`;
link.style.display = '';
startPolling();
} else {
setLiveError(data.error || 'Unknown error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play me-1"></i>Execute';
}
})
.catch(err => {
setLiveError('Network error: ' + err);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play me-1"></i>Execute';
});
});
// ── Live terminal ────────────────────────────────────────────────────
function resetLiveCard() {
document.getElementById('liveTerminal').textContent = '';
document.getElementById('liveStatusBadge').className = 'badge bg-secondary';
document.getElementById('liveStatusBadge').textContent = 'Starting…';
document.getElementById('liveStatusText').textContent = 'Initializing…';
document.getElementById('livePulseDot').className = 'pulse-dot';
document.getElementById('liveHostSummary').textContent = '';
document.getElementById('liveElapsed').textContent = '';
document.getElementById('liveDetailsLink').style.display = 'none';
document.getElementById('liveStopBtn').style.display = '';
}
function startPolling() {
pollTimer = setInterval(pollLive, 2000);
pollLive();
}
function stopPolling() {
clearInterval(pollTimer);
pollTimer = null;
document.getElementById('liveStopBtn').style.display = 'none';
document.getElementById('liveStatusText').textContent += ' (polling stopped)';
}
function pollLive() {
if (!executionId) return;
fetch(`/api/ansible/executions/${executionId}/live`)
.then(r => r.json())
.then(data => {
if (!data.success) { setLiveError(data.error); return; }
renderLiveData(data);
if (['completed','failed','cancelled','timeout'].includes(data.status)) {
stopPolling();
document.getElementById('liveStopBtn').style.display = 'none';
document.getElementById('executeButton').disabled = false;
document.getElementById('executeButton').innerHTML = '<i class="fas fa-play me-1"></i>Execute';
}
})
.catch(err => console.warn('Poll error:', err));
}
function renderLiveData(data) {
const badge = document.getElementById('liveStatusBadge');
const dot = document.getElementById('livePulseDot');
const colors = { running:'bg-primary', completed:'bg-success', failed:'bg-danger',
cancelled:'bg-warning', timeout:'bg-warning' };
badge.className = 'badge ' + (colors[data.status] || 'bg-secondary');
badge.textContent = data.status.charAt(0).toUpperCase() + data.status.slice(1);
dot.className = data.status === 'running' ? 'pulse-dot' :
data.status === 'completed' ? 'pulse-dot done' : 'pulse-dot error';
document.getElementById('liveStatusText').textContent =
data.summary_message || `Running ${data.playbook_name} on ${(data.target_hosts||[]).join(', ')}`;
if (pollStartTime) {
const sec = Math.round((Date.now() - pollStartTime) / 1000);
document.getElementById('liveElapsed').textContent = `${sec}s elapsed`;
}
if (data.status !== 'running') {
document.getElementById('liveHostSummary').innerHTML =
`${data.successful_hosts||0} ok &nbsp; ❌ ${data.failed_hosts||0} failed &nbsp; ⚠️ ${data.unreachable_hosts||0} unreachable`;
}
const terminal = document.getElementById('liveTerminal');
const colorised = (data.log || '')
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/(PLAY\s+\[.*?\])/g, '<span class="ansi-play">$1</span>')
.replace(/(TASK\s+\[.*?\])/g, '<span class="ansi-task">$1</span>')
.replace(/(ok:.*)/g, '<span class="ansi-ok">$1</span>')
.replace(/(changed:.*)/g, '<span class="ansi-changed">$1</span>')
.replace(/(fatal:.*)/g, '<span class="ansi-fail">$1</span>')
.replace(/(FAILED!.*)/g, '<span class="ansi-fail">$1</span>')
.replace(/(UNREACHABLE!.*)/g, '<span class="ansi-unreachable">$1</span>');
terminal.innerHTML = colorised;
if (autoScroll) terminal.scrollTop = terminal.scrollHeight;
}
function setLiveError(msg) {
document.getElementById('liveStatusBadge').className = 'badge bg-danger';
document.getElementById('liveStatusBadge').textContent = 'Error';
document.getElementById('livePulseDot').className = 'pulse-dot error';
document.getElementById('liveTerminal').textContent = 'Error: ' + msg;
}
function toggleAutoScroll() {
autoScroll = !autoScroll;
document.getElementById('autoScrollIcon').style.opacity = autoScroll ? '1' : '0.3';
}
// ── Initialize ───────────────────────────────────────────────────────
updateTargetCount();
updateSummary();
{% if preselect_playbook %}
selectPlaybook('{{ preselect_playbook }}');
{% endif %}
</script>
{% endblock %}