feat: execution failure reports, auto-printer for WMT, UTC timezone fix for all timestamps

This commit is contained in:
ske087
2026-04-24 15:52:12 +03:00
parent d2485e4c66
commit 056f467791
27 changed files with 1391 additions and 285 deletions

View File

@@ -11,24 +11,7 @@
.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;
}
#execPopupNotice { display: none; }
.pulse-dot {
width: 10px; height: 10px; border-radius: 50%;
background: #4ec9b0; animation: pulse 1.2s infinite; display: inline-block;
@@ -93,6 +76,17 @@
</div>
</div>
</div>
<div class="card playbook-card mb-3" data-name="distribute_ssh_keys" onclick="selectPlaybook('distribute_ssh_keys')">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1 text-warning"><i class="fas fa-key me-1"></i>Distribute SSH Keys</h6>
<p class="small text-muted mb-0">Push server public key to devices using password auth</p>
</div>
<span class="badge bg-warning text-dark 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">
@@ -240,10 +234,22 @@
<option value="3">3 retries</option>
</select>
</div>
<div class="form-check">
<div class="form-check mb-2">
<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 class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="forcePasswordAuth"
name="force_password_auth" role="switch"
{% if use_password_auth %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="forcePasswordAuth">
<i class="fas fa-lock me-1 text-warning"></i>Use password authentication
</label>
<div class="text-muted small mt-1">
Override SSH key auth and connect with the configured device password.
Auto-enabled for <em>Distribute SSH Keys</em>.
</div>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Extra Variables (JSON)</label>
@@ -300,38 +306,22 @@
</form>
<!-- ── Live Execution Output ──────────────────────────────────── -->
<div class="row mt-3" id="liveCard">
<!-- ── Popup launched notice ─────────────────────────────────── -->
<div class="row mt-3" id="execPopupNotice">
<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 class="alert alert-info d-flex align-items-center gap-3 mb-0">
<span class="pulse-dot" id="noticePulseDot"></span>
<div class="flex-grow-1">
<strong>Execution started!</strong>
A live output window has been opened.
If it was blocked by your browser, use the link below.
</div>
<a id="noticePopupLink" href="#" target="_blank" class="btn btn-sm btn-outline-primary flex-shrink-0">
<i class="fas fa-external-link-alt me-1"></i>Open Live Output
</a>
<a id="noticeDetailsLink" href="#" class="btn btn-sm btn-outline-secondary flex-shrink-0">
<i class="fas fa-list me-1"></i>Full Details
</a>
</div>
</div>
</div>
@@ -342,12 +332,7 @@
// ── State ────────────────────────────────────────────────────────────
let selectedPlaybook = null;
let targetMode = 'all';
// Live output state
let pollTimer = null;
let executionId = null;
let autoScroll = true;
let pollStartTime = null;
let executionId = null;
// ── Playbook selection ───────────────────────────────────────────────
function selectPlaybook(name) {
@@ -356,6 +341,15 @@ function selectPlaybook(name) {
document.querySelector(`.playbook-card[data-name="${name}"]`)?.classList.add('selected');
selectedPlaybook = name;
document.getElementById('selectedPlaybook').value = name;
// Auto-enable password auth for the key distribution playbook
const pwdToggle = document.getElementById('forcePasswordAuth');
if (pwdToggle) {
if (name === 'distribute_ssh_keys') {
pwdToggle.checked = true;
}
}
updateSummary();
}
@@ -485,9 +479,6 @@ document.getElementById('executeForm').addEventListener('submit', function(e) {
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' },
@@ -495,112 +486,35 @@ document.getElementById('executeForm').addEventListener('submit', function(e) {
})
.then(r => r.json())
.then(data => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play me-1"></i>Execute';
if (data.success) {
executionId = data.execution_id;
pollStartTime = Date.now();
const link = document.getElementById('liveDetailsLink');
link.href = `/ansible/executions/${executionId}`;
link.style.display = '';
startPolling();
const popupUrl = `/ansible/executions/${executionId}/live-popup`;
const detailUrl = `/ansible/executions/${executionId}`;
// Open independent popup window
window.open(popupUrl, `exec_${executionId}`,
'width=960,height=660,resizable=yes,scrollbars=no,toolbar=no,menubar=no');
// Show notice bar with fallback links
const notice = document.getElementById('execPopupNotice');
notice.style.display = '';
document.getElementById('noticePopupLink').href = popupUrl;
document.getElementById('noticeDetailsLink').href = detailUrl;
document.getElementById('noticePulseDot').className = 'pulse-dot';
} else {
setLiveError(data.error || 'Unknown error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play me-1"></i>Execute';
alert('Execution failed: ' + (data.error || 'Unknown error'));
}
})
.catch(err => {
setLiveError('Network error: ' + err);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play me-1"></i>Execute';
alert('Network error: ' + err);
});
});
// ── 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();