feat: execution failure reports, auto-printer for WMT, UTC timezone fix for all timestamps
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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 & 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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
144
templates/ansible/failure_reports.html
Normal file
144
templates/ansible/failure_reports.html
Normal 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 %}
|
||||
392
templates/ansible/live_popup.html
Normal file
392
templates/ansible/live_popup.html
Normal 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>` +
|
||||
` <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>
|
||||
@@ -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> | ` +
|
||||
`<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 %}
|
||||
|
||||
Reference in New Issue
Block a user