Files
Server_Monitorizare_v2/templates/ansible/live_popup.html

393 lines
15 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ansible Execution — Live Output</title>
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
crossorigin="anonymous" referrerpolicy="no-referrer">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #1e1e1e;
color: #d4d4d4;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* ── Top bar ─────────────────────────────────────────────── */
#topBar {
background: #252526;
border-bottom: 1px solid #3c3c3c;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
#topBar .title {
font-size: .85rem;
font-weight: 600;
color: #ccc;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#statusBadge {
font-size: .75rem;
padding: 3px 10px;
border-radius: 12px;
background: #555;
color: #fff;
flex-shrink: 0;
}
#statusBadge.running { background: #0d6efd; }
#statusBadge.completed { background: #198754; }
#statusBadge.failed { background: #dc3545; }
#statusBadge.cancelled { background: #ffc107; color: #000; }
#statusBadge.timeout { background: #ffc107; color: #000; }
.pulse-dot {
width: 9px; height: 9px; border-radius: 50%;
background: #4ec9b0;
animation: pulse 1.2s infinite;
display: inline-block;
flex-shrink: 0;
}
.pulse-dot.done { background: #6a9955; animation: none; }
.pulse-dot.error { background: #f44747; animation: none; }
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:.3;} }
/* ── Info bar ────────────────────────────────────────────── */
#infoBar {
background: #2d2d2d;
border-bottom: 1px solid #3c3c3c;
padding: 6px 16px;
display: flex;
gap: 20px;
flex-wrap: wrap;
font-size: .75rem;
color: #9d9d9d;
flex-shrink: 0;
}
#infoBar span strong { color: #ccc; }
/* ── Terminal ────────────────────────────────────────────── */
#terminal {
flex: 1;
overflow-y: auto;
background: #1e1e1e;
padding: 12px 16px;
font-family: 'Courier New', 'Consolas', monospace;
font-size: .78rem;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-all;
}
#terminal .ansi-ok { color: #4ec9b0; }
#terminal .ansi-changed { color: #dcdcaa; }
#terminal .ansi-fail { color: #f44747; }
#terminal .ansi-unreachable { color: #ce9178; }
#terminal .ansi-task { color: #9cdcfe; font-weight: bold; }
#terminal .ansi-play { color: #c586c0; font-weight: bold; }
#terminal .ansi-recap { color: #569cd6; font-weight: bold; }
#terminal .ansi-skipped { color: #808080; }
#terminal .ansi-warning { color: #ff8c00; }
#placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: #555;
}
#placeholder .spinner {
width: 36px; height: 36px;
border: 3px solid #333;
border-top-color: #4ec9b0;
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Footer ─────────────────────────────────────────────── */
#footer {
background: #252526;
border-top: 1px solid #3c3c3c;
padding: 7px 16px;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
font-size: .75rem;
gap: 10px;
}
#hostSummary { color: #9d9d9d; }
#elapsed { color: #6a9955; }
.btn-sm {
padding: 3px 10px;
font-size: .73rem;
border-radius: 4px;
border: 1px solid #555;
background: transparent;
color: #ccc;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
text-decoration: none;
}
.btn-sm:hover { background: #3c3c3c; }
.btn-sm.danger { border-color: #dc3545; color: #f44747; }
.btn-sm.danger:hover { background: #3c1a1a; }
.btn-sm.primary { border-color: #0d6efd; color: #6ea8fe; }
.btn-sm.primary:hover { background: #1a2a3c; }
.btn-group { display: flex; gap: 6px; }
</style>
</head>
<body>
<!-- ── Top bar ──────────────────────────────────────────────────── -->
<div id="topBar">
<span class="pulse-dot" id="pulseDot"></span>
<span class="title" id="titleText">
<i class="fas fa-terminal" style="color:#4ec9b0;margin-right:6px;"></i>
Connecting to execution <code style="color:#9cdcfe;">{{ execution_id[:8] }}…</code>
</span>
<span id="statusBadge">Waiting…</span>
</div>
<!-- ── Info bar ─────────────────────────────────────────────────── -->
<div id="infoBar">
<span><strong>Playbook:</strong> <span id="infoPlaybook"></span></span>
<span><strong>Hosts:</strong> <span id="infoHosts"></span></span>
<span><strong>Elapsed:</strong> <span id="elapsed">0s</span></span>
<span id="infoExtra"></span>
</div>
<!-- ── Terminal output ─────────────────────────────────────────── -->
<div id="terminal">
<div id="placeholder">
<div class="spinner"></div>
<div>Waiting for execution output…</div>
</div>
</div>
<!-- ── Footer ───────────────────────────────────────────────────── -->
<div id="footer">
<span id="hostSummary"></span>
<div class="btn-group">
<button class="btn-sm" id="autoScrollBtn" onclick="toggleAutoScroll()">
<i class="fas fa-arrow-down" id="autoScrollIcon"></i> Auto-scroll
</button>
<a id="detailsLink" class="btn-sm primary"
href="/ansible/executions/{{ execution_id }}" target="_blank">
<i class="fas fa-external-link-alt"></i> Full Details
</a>
<button class="btn-sm" id="saveReportBtn" onclick="saveFailureReport()"
style="display:none;background:#c0392b;color:#fff;border:none;">
<i class="fas fa-save"></i> Save Report
</button>
<button class="btn-sm danger" id="stopBtn" onclick="stopPolling()">
<i class="fas fa-stop"></i> Stop polling
</button>
</div>
</div>
<script>
const EXECUTION_ID = "{{ execution_id }}";
const API_URL = `/api/ansible/executions/${EXECUTION_ID}/live`;
let pollTimer = null;
let autoScroll = true;
let startTime = Date.now();
let firstOutput = false;
let elapsedInterval = null;
// ── Start immediately ────────────────────────────────────────────
startPolling();
elapsedInterval = setInterval(() => {
const sec = Math.round((Date.now() - startTime) / 1000);
document.getElementById('elapsed').textContent =
sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`;
}, 1000);
function startPolling() {
pollTimer = setInterval(poll, 2000);
poll();
}
function stopPolling() {
clearInterval(pollTimer);
pollTimer = null;
clearInterval(elapsedInterval);
elapsedInterval = null;
document.getElementById('stopBtn').style.display = 'none';
const dot = document.getElementById('pulseDot');
dot.className = 'pulse-dot done';
appendLine('\n— Polling stopped by user —', '#808080');
}
function poll() {
fetch(API_URL)
.then(r => r.json())
.then(data => {
if (!data.success) { renderError(data.error || 'Unknown error'); return; }
renderData(data);
const done = ['completed','failed','cancelled','timeout'].includes(data.status);
if (done) {
clearInterval(pollTimer);
clearInterval(elapsedInterval);
pollTimer = null;
document.getElementById('stopBtn').style.display = 'none';
}
})
.catch(err => appendLine('Poll error: ' + err, '#f44747'));
}
function renderData(data) {
// Title bar
document.getElementById('titleText').innerHTML =
`<i class="fas fa-terminal" style="color:#4ec9b0;margin-right:6px;"></i>`+
`<strong style="color:#9cdcfe;">${data.playbook_name || EXECUTION_ID.slice(0,8)}</strong>`+
`${(data.target_hosts || []).join(', ') || 'all hosts'}`;
// Status badge
const badge = document.getElementById('statusBadge');
badge.textContent = (data.status || 'unknown').charAt(0).toUpperCase() + (data.status||'').slice(1);
badge.className = data.status || '';
// Pulse dot
const dot = document.getElementById('pulseDot');
dot.className = data.status === 'running' ? 'pulse-dot' :
data.status === 'completed' ? 'pulse-dot done' : 'pulse-dot error';
// Info bar
document.getElementById('infoPlaybook').textContent = data.playbook_name || '—';
document.getElementById('infoHosts').textContent = (data.target_hosts || []).join(', ') || '—';
if (data.summary_message) {
document.getElementById('infoExtra').innerHTML =
`<strong>Status:</strong> ${escHtml(data.summary_message)}`;
}
// Host summary (when done)
if (!['running','queued'].includes(data.status)) {
document.getElementById('hostSummary').innerHTML =
`<span style="color:#4ec9b0">✓ ${data.successful_hosts||0} ok</span>` +
` &nbsp; <span style="color:#f44747">✗ ${data.failed_hosts||0} failed</span>` +
` &nbsp; <span style="color:#ce9178">⚠ ${data.unreachable_hosts||0} unreachable</span>`;
// Show "Save Report" button only when there are failures/unreachable hosts
const hasFailures = (data.failed_hosts > 0 || data.unreachable_hosts > 0);
const saveBtn = document.getElementById('saveReportBtn');
if (hasFailures && !window._reportSaved) {
saveBtn.style.display = '';
}
}
// Terminal output
const log = data.log || '';
if (log) {
if (!firstOutput) {
document.getElementById('placeholder').remove();
firstOutput = true;
}
const term = document.getElementById('terminal');
term.innerHTML = colorize(escHtml(log));
if (autoScroll) term.scrollTop = term.scrollHeight;
}
}
function renderError(msg) {
document.getElementById('pulseDot').className = 'pulse-dot error';
document.getElementById('statusBadge').textContent = 'Error';
document.getElementById('statusBadge').className = 'failed';
if (!firstOutput) {
document.getElementById('placeholder').remove();
firstOutput = true;
}
document.getElementById('terminal').innerHTML =
`<span style="color:#f44747">Error: ${escHtml(msg)}</span>`;
}
function appendLine(text, color) {
const term = document.getElementById('terminal');
const span = document.createElement('span');
span.style.color = color || '#d4d4d4';
span.textContent = text + '\n';
term.appendChild(span);
if (autoScroll) term.scrollTop = term.scrollHeight;
}
function colorize(html) {
return html
.replace(/(PLAY\s+\[.*?\](?:\s*\*+)?)/g, '<span class="ansi-play">$1</span>')
.replace(/(TASK\s+\[.*?\](?:\s*\*+)?)/g, '<span class="ansi-task">$1</span>')
.replace(/(PLAY RECAP(?:\s*\*+)?)/g, '<span class="ansi-recap">$1</span>')
.replace(/(ok:\s+\[.*?\].*)/g, '<span class="ansi-ok">$1</span>')
.replace(/(changed:\s+\[.*?\].*)/g, '<span class="ansi-changed">$1</span>')
.replace(/(skipping:\s+\[.*?\].*)/g, '<span class="ansi-skipped">$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>')
.replace(/(\[WARNING\].*)/g, '<span class="ansi-warning">$1</span>');
}
function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function toggleAutoScroll() {
autoScroll = !autoScroll;
document.getElementById('autoScrollIcon').style.opacity = autoScroll ? '1' : '0.35';
document.getElementById('autoScrollBtn').style.borderColor = autoScroll ? '#555' : '#ffc107';
}
async function saveFailureReport() {
const btn = document.getElementById('saveReportBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving…';
try {
const r = await fetch('/api/ansible/failure-reports', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({execution_id: EXECUTION_ID})
});
const d = await r.json();
if (d.success) {
btn.style.background = '#27ae60';
btn.innerHTML = '<i class="fas fa-check"></i> Saved';
window._reportSaved = true;
} else if (r.status === 409) {
btn.style.background = '#7f8c8d';
btn.innerHTML = '<i class="fas fa-info-circle"></i> Already saved';
window._reportSaved = true;
} else {
btn.disabled = false;
btn.style.background = '#c0392b';
btn.innerHTML = '<i class="fas fa-exclamation-circle"></i> Failed — retry';
appendLine('Error saving report: ' + (d.error || 'unknown'), '#f44747');
}
} catch(e) {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-exclamation-circle"></i> Error';
appendLine('Network error: ' + e, '#f44747');
}
}
// Close popup when parent window unloads (optional: keep open)
// window.addEventListener('beforeunload', stopPolling);
</script>
</body>
</html>