feat: execution failure reports, auto-printer for WMT, UTC timezone fix for all timestamps
This commit is contained in:
@@ -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 ❌ ${data.failed_hosts||0} failed ⚠️ ${data.unreachable_hosts||0} unreachable`;
|
||||
}
|
||||
const terminal = document.getElementById('liveTerminal');
|
||||
const colorised = (data.log || '')
|
||||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.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();
|
||||
|
||||
Reference in New Issue
Block a user