393 lines
15 KiB
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>` +
|
|
` <span style="color:#f44747">✗ ${data.failed_hosts||0} failed</span>` +
|
|
` <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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
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>
|