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

@@ -319,7 +319,7 @@
<span class="badge bg-danger">Offline</span>
{% endif %}
</td>
<td>{{ device.last_check.strftime('%Y-%m-%d %H:%M') if device.last_check else 'Never' }}</td>
<td>{{ device.last_check | local_dt if device.last_check else 'Never' }}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="testDevice('{{ device.name }}')">
<i class="fas fa-plug"></i>
@@ -482,7 +482,7 @@
</div>
<p class="card-text text-muted small">
<i class="fas fa-clock"></i>
{{ execution.start_time.strftime('%Y-%m-%d %H:%M:%S') if execution.start_time else 'N/A' }}
{{ execution.start_time | local_dt('%Y-%m-%d %H:%M:%S') if execution.start_time else 'N/A' }}
</p>
<div class="row text-center">
<div class="col">

View File

@@ -21,9 +21,11 @@
{% for g in inventory.groups.values() %}{% set ns.total = ns.total + g.hosts|length %}{% endfor %}
<span class="badge bg-primary ms-2">{{ ns.total }}</span>
</span>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-success" onclick="syncDevices()">
<i class="fas fa-sync-alt me-1"></i>Sync All
<div class="d-flex align-items-center gap-1">
<span id="lastSyncedLabel" class="text-muted me-1" style="font-size:.72rem;"></span>
<button class="btn btn-sm btn-success" onclick="syncDevices()" id="syncBtn"
title="Pull latest hostnames &amp; IPs from the monitoring database">
<i class="fas fa-database me-1"></i>Sync IPs from DB
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="toggleRaw()" title="Raw YAML">
<i class="fas fa-code"></i>
@@ -87,7 +89,7 @@
{% else %}
<div class="card-body text-center py-5 text-muted">
<i class="fas fa-box-open fa-3x mb-3"></i>
<p class="mb-0">Inventory is empty. Use <strong>Sync All</strong> or add from below.</p>
<p class="mb-0">Inventory is empty. Click <strong>Sync IPs from DB</strong> to import all active devices.</p>
</div>
{% endif %}
</div>
@@ -414,22 +416,24 @@ function togglePwField(val) {
document.getElementById('pwField').classList.toggle('d-none', val !== 'password');
}
/* ── Sync all DB devices into monitoring_devices ── */
/* ── Sync IPs from monitoring DB into inventory ── */
async function syncDevices() {
const btn = event.currentTarget;
const btn = document.getElementById('syncBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Syncing…';
try {
const r = await fetch(`${API}/inventory/sync`, {method:'POST'});
const d = await r.json();
if (d.success) {
const now = new Date().toLocaleTimeString();
document.getElementById('lastSyncedLabel').textContent = `Last synced: ${now}`;
showAlert(`<i class="fas fa-check-circle me-1"></i> ${d.message}`, 'success');
setTimeout(() => location.reload(), 1200);
} else { showAlert(`Sync failed: ${d.error}`, 'danger'); }
} catch { showAlert('Network error', 'danger'); }
finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-sync-alt me-1"></i>Sync All';
btn.innerHTML = '<i class="fas fa-database me-1"></i>Sync IPs from DB';
}
}

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();

View File

@@ -141,7 +141,7 @@
<div class="row">
<div class="col-6">
<small class="text-muted">Started:</small><br>
{{ execution.started_at.strftime('%Y-%m-%d %H:%M') if execution.started_at else 'Queued' }}
{{ execution.started_at | local_dt if execution.started_at else 'Queued' }}
</div>
<div class="col-6">
<small class="text-muted">Duration:</small><br>

View File

@@ -0,0 +1,144 @@
{% extends "base.html" %}
{% block title %}Execution Failure Reports — Server Monitoring{% endblock %}
{% block page_title %}Execution Failure Reports{% endblock %}
{% block content %}
<div class="container-fluid">
<div id="alertArea"></div>
<!-- Header row -->
<div class="d-flex justify-content-between align-items-center mb-3">
<p class="text-muted mb-0">
Saved records of failed or unreachable hosts from completed playbook executions.
</p>
<span class="badge bg-secondary fs-6">{{ reports | length }} report(s)</span>
</div>
{% if reports %}
<div class="row g-3" id="reportsList">
{% for report in reports %}
<div class="col-12" id="report-{{ report.id }}">
<div class="card shadow-sm border-{% if report.unreachable_count > 0 and report.failed_count == 0 %}warning{% elif report.failed_count > 0 %}danger{% else %}secondary{% endif %}">
<!-- Card header -->
<div class="card-header d-flex justify-content-between align-items-start flex-wrap gap-2 py-2">
<div>
<i class="fas fa-exclamation-triangle me-2 text-{% if report.failed_count > 0 %}danger{% else %}warning{% endif %}"></i>
<strong>{{ report.playbook_name }}</strong>
<span class="text-muted ms-2" style="font-size:.82rem;">
Execution <code>{{ report.execution_id[:8] }}…</code>
</span>
</div>
<div class="d-flex align-items-center gap-2">
{% if report.failed_count > 0 %}
<span class="badge bg-danger"><i class="fas fa-times-circle me-1"></i>{{ report.failed_count }} failed</span>
{% endif %}
{% if report.unreachable_count > 0 %}
<span class="badge bg-warning text-dark"><i class="fas fa-plug me-1"></i>{{ report.unreachable_count }} unreachable</span>
{% endif %}
<span class="text-muted" style="font-size:.78rem;">
<i class="fas fa-calendar-alt me-1"></i>{{ report.saved_at[:19].replace('T',' ') }}
</span>
<a href="/ansible/executions/{{ report.execution_id }}" class="btn btn-sm btn-outline-secondary" target="_blank"
title="View full execution">
<i class="fas fa-external-link-alt"></i>
</a>
<button class="btn btn-sm btn-outline-danger" onclick="deleteReport({{ report.id }})"
title="Delete this report">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<!-- Host list -->
<div class="card-body py-2 px-0">
{% if report.note %}
<div class="px-3 pb-2">
<i class="fas fa-sticky-note text-muted me-1"></i>
<small class="text-muted">{{ report.note }}</small>
</div>
{% endif %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width:50%">Hostname</th>
<th style="width:20%">Status</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{% for host in report.failed_hosts %}
<tr>
<td><strong>{{ host.hostname }}</strong></td>
<td>
{% if host.status == 'unreachable' %}
<span class="badge bg-warning text-dark">unreachable</span>
{% else %}
<span class="badge bg-danger">failed</span>
{% endif %}
</td>
<td class="text-muted" style="font-size:.85rem;">{{ host.reason }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card">
<div class="card-body text-center py-5 text-muted">
<i class="fas fa-check-circle fa-3x mb-3 text-success"></i>
<p class="mb-0">No failure reports saved yet.<br>
Use the <strong>Save Report</strong> button in the execution popup when a playbook has failed or unreachable hosts.
</p>
</div>
</div>
{% endif %}
</div>
<script>
const API = '/api/ansible';
function showAlert(html, type='info') {
const area = document.getElementById('alertArea');
area.innerHTML = `<div class="alert alert-${type} alert-dismissible fade show">
${html}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>`;
setTimeout(() => { if (area.firstChild) area.firstChild.remove(); }, 5000);
}
async function deleteReport(reportId) {
if (!confirm('Delete this failure report?')) return;
try {
const r = await fetch(`${API}/failure-reports/${reportId}`, {method:'DELETE'});
const d = await r.json();
if (d.success) {
document.getElementById(`report-${reportId}`).remove();
showAlert('<i class="fas fa-check-circle me-1"></i>Report deleted.', 'success');
// Show empty state if no more reports
if (!document.querySelector('#reportsList .col-12')) {
document.getElementById('reportsList').innerHTML =
'<div class="col-12"><div class="card"><div class="card-body text-center py-5 text-muted">' +
'<i class="fas fa-check-circle fa-3x mb-3 text-success"></i>' +
'<p class="mb-0">No failure reports saved yet.</p></div></div></div>';
}
} else {
showAlert(`Error: ${d.error}`, 'danger');
}
} catch(e) {
showAlert('Network error: ' + e, 'danger');
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,392 @@
<!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>

View File

@@ -63,18 +63,18 @@
</div>
<div class="card-body">
<p class="text-muted small">
When key-based authentication fails, the server falls back to password auth.
Set the default password for devices on this network below.
Configure SSH authentication for Ansible. Enable password mode to authenticate
with a username/password instead of SSH keys.
</p>
<form method="post" action="{{ url_for('ansible_web.save_ssh_settings') }}">
<div class="mb-3">
<label class="form-label fw-semibold">SSH Fallback Password</label>
<label class="form-label fw-semibold">SSH Password</label>
<div class="input-group">
<input type="password" name="ssh_fallback_password" id="sshFallbackPassword"
class="form-control"
value="{{ settings.get('ssh_fallback_password', '') }}"
placeholder="Enter fallback password"
placeholder="Enter device password"
required>
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword()">
@@ -86,6 +86,19 @@
</small>
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" name="use_password_auth"
id="usePasswordAuth" role="switch"
{% if settings.get('use_password_auth') %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="usePasswordAuth">
Use password authentication (instead of SSH keys)
</label>
<div class="text-muted small mt-1">
When enabled, Ansible will connect to all devices using the password above.
SSH key files will be ignored.
</div>
</div>
<button type="submit" class="btn btn-success">
<i class="fas fa-save me-1"></i>Save Settings
</button>
@@ -95,6 +108,87 @@
</div>
</div><!-- /row -->
<!-- Test Password row -->
<div class="row g-4 mt-1">
<div class="col-12">
<div class="card shadow-sm border-info">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="fas fa-vial me-2"></i>Test Password Authentication</h5>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Verify the password above works on a specific device <strong>before</strong> running the full key deployment.
This connects with password-only auth (no SSH key) so you get an accurate pre-flight result.
</p>
<div class="row g-2 align-items-end">
<div class="col-sm-5">
<label class="form-label fw-semibold mb-1">Device IP</label>
<input type="text" id="testIpInput" class="form-control"
placeholder="e.g. 10.76.157.145" autocomplete="off">
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold mb-1">Password <span class="text-muted fw-normal">(leave blank to use saved)</span></label>
<input type="password" id="testPasswordInput" class="form-control"
placeholder="Uses saved password if empty" autocomplete="off">
</div>
<div class="col-sm-3">
<button id="testPasswordBtn" class="btn btn-info w-100" onclick="testPasswordAuth()">
<i class="fas fa-plug me-1"></i>Test Connection
</button>
</div>
</div>
<div id="testPasswordResult" class="mt-3" style="display:none;"></div>
</div>
</div>
</div>
</div>
<!-- Deploy SSH Keys row -->
<div class="row g-4 mt-1">
<div class="col-12">
<div class="card shadow-sm border-warning">
<div class="card-header bg-warning text-dark">
<h5 class="mb-0"><i class="fas fa-rocket me-2"></i>Deploy SSH Keys to All Devices</h5>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-lg-8">
<p class="mb-1">
Connects to every device <strong>using the password</strong> configured above
and copies <code>~/.ssh/id_rsa.pub</code> into each device's
<code>~/.ssh/authorized_keys</code>.
</p>
<p class="text-muted small mb-0">
<i class="fas fa-info-circle me-1"></i>
Run this once to bootstrap key-based auth. Afterwards, disable
<em>"Use password authentication"</em> so all playbooks switch to SSH keys automatically.
</p>
</div>
<div class="col-lg-4 text-lg-end mt-3 mt-lg-0">
{% if not settings.get('ssh_fallback_password') %}
<div class="alert alert-warning py-2 mb-2 small">
<i class="fas fa-exclamation-triangle me-1"></i>
Set the SSH Password first, then save settings.
</div>
{% endif %}
<button id="deployKeysBtn" class="btn btn-warning btn-lg"
{% if not settings.get('ssh_fallback_password') %}disabled{% endif %}
onclick="deploySSHKeys()">
<i class="fas fa-key me-1"></i>Deploy SSH Keys to All Devices
</button>
</div>
</div>
<div id="deployStatusBar" class="mt-3" style="display:none;">
<div class="alert alert-info mb-0" id="deployStatusMsg">
<span class="spinner-border spinner-border-sm me-2"></span>Starting deployment…
</div>
</div>
</div>
</div>
</div>
</div>
</div><!-- /container -->
<script>
@@ -109,5 +203,95 @@ function togglePassword() {
icon.classList.replace('fa-eye-slash', 'fa-eye');
}
}
function testPasswordAuth() {
const ip = document.getElementById('testIpInput').value.trim();
const pw = document.getElementById('testPasswordInput').value;
const btn = document.getElementById('testPasswordBtn');
const result = document.getElementById('testPasswordResult');
if (!ip) {
result.style.display = '';
result.innerHTML = '<div class="alert alert-warning py-2 mb-0"><i class="fas fa-exclamation-triangle me-2"></i>Enter a device IP first.</div>';
return;
}
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Testing…';
result.style.display = '';
result.innerHTML = '<div class="alert alert-secondary py-2 mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Connecting to ' + ip + '…</div>';
const body = { device_ip: ip };
if (pw) body.password = pw;
fetch('/api/ansible/ssh/test-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify(body)
})
.then(r => r.json())
.then(data => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-plug me-1"></i>Test Connection';
if (data.success) {
result.innerHTML = '<div class="alert alert-success py-2 mb-0"><i class="fas fa-check-circle me-2"></i>' + (data.message || 'Authentication succeeded!') + '</div>';
} else {
const reachable = data.reachable;
const icon = reachable === false ? 'fa-times-circle' : 'fa-key';
const cls = reachable === false ? 'alert-warning' : 'alert-danger';
result.innerHTML = '<div class="alert ' + cls + ' py-2 mb-0"><i class="fas ' + icon + ' me-2"></i>' + (data.error || 'Connection failed') + '</div>';
}
})
.catch(err => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-plug me-1"></i>Test Connection';
result.innerHTML = '<div class="alert alert-danger py-2 mb-0"><i class="fas fa-times-circle me-2"></i>Network error: ' + err + '</div>';
});
}
function deploySSHKeys() {
const btn = document.getElementById('deployKeysBtn');
const bar = document.getElementById('deployStatusBar');
const msg = document.getElementById('deployStatusMsg');
if (!confirm('Deploy the server SSH public key to ALL devices using the configured password?\n\nThis will add the key to each device\'s authorized_keys file.')) return;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Deploying…';
bar.style.display = '';
msg.className = 'alert alert-info mb-0';
msg.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting deployment…';
fetch('/api/ansible/ssh/distribute-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({})
})
.then(r => r.json())
.then(data => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-key me-1"></i>Deploy SSH Keys to All Devices';
if (data.success) {
const execId = data.execution_id;
const popupUrl = `/ansible/executions/${execId}/live-popup`;
const detailUrl = `/ansible/executions/${execId}`;
window.open(popupUrl, `deploy_keys_${execId}`,
'width=960,height=660,resizable=yes,scrollbars=no,toolbar=no,menubar=no');
msg.className = 'alert alert-success mb-0';
msg.innerHTML = `<i class="fas fa-check-circle me-2"></i>Deployment started. ` +
`<a href="${popupUrl}" target="_blank">Open live output</a> &nbsp;|&nbsp; ` +
`<a href="${detailUrl}">View details</a>`;
} else {
msg.className = 'alert alert-danger mb-0';
msg.innerHTML = `<i class="fas fa-times-circle me-2"></i>Error: ${data.error || 'Unknown error'}`;
}
})
.catch(err => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-key me-1"></i>Deploy SSH Keys to All Devices';
msg.className = 'alert alert-danger mb-0';
msg.innerHTML = `<i class="fas fa-times-circle me-2"></i>Network error: ${err}`;
});
}
</script>
{% endblock %}