Initial commit: enterprise digital platform with portal SSO, DigiServer, IT Assets, NetworkView, Server Monitor

This commit is contained in:
ske087
2026-05-10 21:07:50 +03:00
commit 8d9df56b0b
364 changed files with 73655 additions and 0 deletions
@@ -0,0 +1,664 @@
{% extends "base.html" %}
{% block title %}Ansible Management Dashboard - Server Monitoring{% endblock %}
{% block page_title %}Ansible Management Dashboard{% endblock %}
{% block extra_css %}
<style>
.card-stat {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 15px;
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
transition: transform 0.3s ease;
}
.card-stat:hover {
transform: translateY(-5px);
}
.card-stat h3 {
font-size: 2.5rem;
font-weight: bold;
}
.device-card {
border: none;
border-radius: 12px;
box-shadow: 0 2px 15px rgba(0,0,0,0.08);
transition: all 0.3s ease;
cursor: pointer;
}
.device-card:hover {
transform: translateY(-3px);
box-shadow: 0 5px 25px rgba(0,0,0,0.15);
}
.device-card.selected {
border: 2px solid #28a745;
background-color: #f8fff9;
}
.playbook-card {
border: none;
border-radius: 12px;
box-shadow: 0 2px 15px rgba(0,0,0,0.08);
transition: all 0.3s ease;
}
.playbook-card:hover {
transform: translateY(-3px);
}
.execution-card {
border: none;
border-radius: 15px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
transition: transform 0.2s;
}
.execution-card:hover {
transform: translateY(-5px);
}
.status-running { color: #0d6efd; }
.status-completed { color: #198754; }
.status-failed { color: #dc3545; }
/* Tab styles */
.nav-tabs .nav-link {
border: none;
border-radius: 25px 25px 0 0;
margin-right: 5px;
padding: 12px 20px;
font-weight: 500;
color: #6c757d;
background-color: #f8f9fa;
}
.nav-tabs .nav-link:hover {
background-color: #e9ecef;
color: #495057;
}
.nav-tabs .nav-link.active {
background-color: #3498db;
color: white;
border: none;
}
.tab-content {
border: 1px solid #dee2e6;
border-top: none;
border-radius: 0 15px 15px 15px;
background-color: white;
min-height: 400px;
}
/* Button styles */
.btn-custom {
border-radius: 25px;
padding: 10px 20px;
font-weight: 500;
transition: all 0.3s ease;
}
.btn-custom:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Ansible Management Tabs -->
<div class="row mb-4">
<div class="col-12">
<ul class="nav nav-tabs" id="ansibleTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="automation-tab" data-bs-toggle="tab" data-bs-target="#automation" type="button" role="tab">
<i class="fas fa-robot"></i> Automation Overview
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="devices-tab" data-bs-toggle="tab" data-bs-target="#devices" type="button" role="tab">
<i class="fas fa-network-wired"></i> Remote Devices
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="playbooks-tab" data-bs-toggle="tab" data-bs-target="#playbooks" type="button" role="tab">
<i class="fas fa-play"></i> Playbook Management
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="execution-tab" data-bs-toggle="tab" data-bs-target="#execution" type="button" role="tab">
<i class="fas fa-cogs"></i> Execution
</button>
</li>
</ul>
</div>
</div>
<!-- Tab Content -->
<div class="tab-content" id="ansibleTabContent">
<!-- Automation Overview Tab -->
<div class="tab-pane fade show active" id="automation" role="tabpanel">
<div class="row mb-4">
<div class="col-md-3">
<div class="card card-stat">
<div class="card-body text-center">
<i class="fas fa-server fa-2x mb-2"></i>
<h3>{{ stats.get('total_devices', 0) }}</h3>
<p>Devices</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card card-stat" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<div class="card-body text-center">
<i class="fas fa-play-circle fa-2x mb-2"></i>
<h3>{{ ((playbooks|default([])|length) + (builtin_playbooks|default([])|length)) }}</h3>
<p>Playbooks</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card card-stat" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<div class="card-body text-center">
<i class="fas fa-tasks fa-2x mb-2"></i>
<h3>{{ (executions|default([]))|length }}</h3>
<p>Executions</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card card-stat" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
<div class="card-body text-center">
<i class="fas fa-check-circle fa-2x mb-2"></i>
<h3>{{ (executions|default([]) | selectattr('status', 'equalto', 'completed') | list | length) }}</h3>
<p>Success Rate</p>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>Quick Actions</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<button class="btn btn-primary btn-custom w-100" onclick="executeQuickPlaybook()">
<i class="fas fa-play"></i> Quick Execute
</button>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-custom w-100" onclick="refreshAll()">
<i class="fas fa-sync"></i> Refresh All
</button>
</div>
<div class="col-md-3">
<button class="btn btn-info btn-custom w-100" data-bs-toggle="modal" data-bs-target="#addDeviceModal">
<i class="fas fa-plus"></i> Add Device
</button>
</div>
<div class="col-md-3">
<button class="btn btn-warning btn-custom w-100" data-bs-toggle="modal" data-bs-target="#uploadPlaybookModal">
<i class="fas fa-upload"></i> Upload Playbook
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Remote Devices Tab -->
<div class="tab-pane fade" id="devices" role="tabpanel">
<div class="row mb-4">
<div class="col-md-8">
<h4>Managed Devices</h4>
<small class="text-muted">Configure and manage devices through Ansible</small>
</div>
<div class="col-md-4 text-end">
<button class="btn btn-primary btn-custom" data-bs-toggle="modal" data-bs-target="#addDeviceModal">
<i class="fas fa-plus"></i> Add Device
</button>
<button class="btn btn-success btn-custom" onclick="refreshInventory()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
</div>
<!-- Device Statistics -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<h5 class="card-title">Total Devices</h5>
<h2>{{ (devices|default([]))|length }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<h5 class="card-title">Online</h5>
<h2>{{ (devices|default([]) | selectattr('status', 'equalto', 'active') | list | length) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-white">
<div class="card-body">
<h5 class="card-title">Offline</h5>
<h2>{{ (devices|default([]) | selectattr('status', 'equalto', 'inactive') | list | length) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body">
<h5 class="card-title">Groups</h5>
<h2>{{ (device_groups|default([]))|length }}</h2>
</div>
</div>
</div>
</div>
<!-- Devices Table -->
<div class="card">
<div class="card-header">
<h5>Device List</h5>
</div>
<div class="card-body">
{% if devices|default([]) %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Work Place</th>
<th>Host</th>
<th>Group</th>
<th>Status</th>
<th>Last Check</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for device in devices|default([]) %}
<tr>
<td>{{ device.name }}</td>
<td>{{ device.host }}</td>
<td><span class="badge bg-secondary">{{ device.group }}</span></td>
<td>
{% if device.status == 'active' %}
<span class="badge bg-success">Online</span>
{% else %}
<span class="badge bg-danger">Offline</span>
{% endif %}
</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>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="removeDevice('{{ device.name }}')">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-server fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No devices configured</h5>
<p class="text-muted">Add your first device to start managing infrastructure.</p>
<button class="btn btn-primary btn-custom" data-bs-toggle="modal" data-bs-target="#addDeviceModal">
<i class="fas fa-plus"></i> Add Device
</button>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Playbook Management Tab -->
<div class="tab-pane fade" id="playbooks" role="tabpanel">
<div class="row mb-4">
<div class="col-md-8">
<h4>Playbook Management</h4>
<small class="text-muted">Manage and execute Ansible playbooks</small>
</div>
<div class="col-md-4 text-end">
<button class="btn btn-primary btn-custom" data-bs-toggle="modal" data-bs-target="#uploadPlaybookModal">
<i class="fas fa-upload"></i> Upload Playbook
</button>
<button class="btn btn-success btn-custom" onclick="refreshPlaybooks()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
</div>
<!-- Playbook Statistics -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<h5 class="card-title">Total Playbooks</h5>
<h2>{{ ((playbooks|default([]))|length + (builtin_playbooks|default([]))|length) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body">
<h5 class="card-title">Custom</h5>
<h2>{{ (playbooks|default([]))|length }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<h5 class="card-title">Built-in</h5>
<h2>{{ (builtin_playbooks|default([]))|length }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-white">
<div class="card-body">
<h5 class="card-title">Recent Runs</h5>
<h2>{{ (executions|default([]))|length }}</h2>
</div>
</div>
</div>
</div>
<!-- Playbooks Grid -->
<div class="row">
{% if (builtin_playbooks|default([])) or (playbooks|default([])) %}
{% for playbook in (builtin_playbooks|default([])) + (playbooks|default([])) %}
<div class="col-md-6 col-lg-4 mb-3">
<div class="card playbook-card">
<div class="card-body">
<h6 class="card-title">{{ playbook.name }}</h6>
<p class="card-text text-muted small">{{ playbook.description | default('No description available') }}</p>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
{% if playbook.type == 'builtin' %}
<span class="badge bg-primary">Built-in</span>
{% else %}
<span class="badge bg-info">Custom</span>
{% endif %}
</small>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick="viewPlaybook('{{ playbook.name }}')">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-outline-success" onclick="executePlaybook('{{ playbook.name }}')">
<i class="fas fa-play"></i>
</button>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-12">
<div class="text-center py-5">
<i class="fas fa-play fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No playbooks available</h5>
<p class="text-muted">Upload a custom playbook to get started.</p>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadPlaybookModal">
<i class="fas fa-upload"></i> Upload Playbook
</button>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Execution Tab -->
<div class="tab-pane fade" id="execution" role="tabpanel">
<div class="row mb-4">
<div class="col-md-8">
<h4>Playbook Execution</h4>
<small class="text-muted">Execute playbooks on selected devices</small>
</div>
<div class="col-md-4 text-end">
<button class="btn btn-primary btn-custom" onclick="executeQuickPlaybook()">
<i class="fas fa-play"></i> Quick Execute
</button>
</div>
</div>
<!-- Recent Executions -->
<div class="card">
<div class="card-header">
<h5>Recent Executions</h5>
</div>
<div class="card-body">
{% if executions|default([]) %}
<div class="row">
{% for execution in (executions|default([]))[:6] %}
<div class="col-md-6 mb-3">
<div class="card execution-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="card-title mb-0">{{ execution.playbook_name }}</h6>
<span class="badge
{% if execution.status == 'running' %}bg-primary status-running{% endif %}
{% if execution.status == 'completed' %}bg-success status-completed{% endif %}
{% if execution.status == 'failed' %}bg-danger status-failed{% endif %}">
{{ execution.status | title }}
</span>
</div>
<p class="card-text text-muted small">
<i class="fas fa-clock"></i>
{{ 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">
<small class="text-success">
<i class="fas fa-check"></i> {{ execution.successful_hosts or 0 }}
</small>
</div>
<div class="col">
<small class="text-danger">
<i class="fas fa-times"></i> {{ execution.failed_hosts or 0 }}
</small>
</div>
<div class="col">
<small class="text-warning">
<i class="fas fa-question"></i> {{ execution.unreachable_hosts or 0 }}
</small>
</div>
</div>
<div class="mt-2">
<button class="btn btn-outline-primary btn-sm" onclick="viewExecution('{{ execution.id }}')">
<i class="fas fa-eye"></i> Details
</button>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-tasks fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No executions yet</h5>
<p class="text-muted">Execute your first playbook to see results here.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Modals -->
<!-- Add Device Modal -->
<div class="modal fade" id="addDeviceModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add New Device</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="addDeviceForm">
<div class="mb-3">
<label class="form-label">Work Place</label>
<input type="text" class="form-control" name="name" required>
</div>
<div class="mb-3">
<label class="form-label">Host/IP Address</label>
<input type="text" class="form-control" name="host" required>
</div>
<div class="mb-3">
<label class="form-label">Group</label>
<select class="form-control" name="group">
<option value="servers">Servers</option>
<option value="workstations">Workstations</option>
<option value="routers">Network Devices</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">SSH User</label>
<input type="text" class="form-control" name="user" value="ansible">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="addDevice()">Add Device</button>
</div>
</div>
</div>
</div>
<!-- Upload Playbook Modal -->
<div class="modal fade" id="uploadPlaybookModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Upload Playbook</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="uploadPlaybookForm" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label">Playbook File (.yml/.yaml)</label>
<input type="file" class="form-control" name="playbook_file" accept=".yml,.yaml" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea class="form-control" name="description" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="uploadPlaybook()">Upload</button>
</div>
</div>
</div>
</div>
<script>
function executeQuickPlaybook() {
// Implementation for quick playbook execution
alert('Quick execute functionality');
}
function refreshAll() {
location.reload();
}
function refreshInventory() {
// Implementation for refreshing inventory
alert('Refreshing inventory...');
}
function refreshPlaybooks() {
// Implementation for refreshing playbooks
alert('Refreshing playbooks...');
}
function testDevice(deviceName) {
// Implementation for testing device connection
alert('Testing connection to ' + deviceName);
}
function removeDevice(deviceName) {
if (confirm('Are you sure you want to remove device: ' + deviceName + '?')) {
// Implementation for removing device
alert('Removing device: ' + deviceName);
}
}
function viewPlaybook(playbookName) {
// Implementation for viewing playbook details
alert('Viewing playbook: ' + playbookName);
}
function executePlaybook(playbookName) {
// Implementation for executing playbook
alert('Executing playbook: ' + playbookName);
}
function viewExecution(executionId) {
// Implementation for viewing execution details
alert('Viewing execution: ' + executionId);
}
function addDevice() {
// Implementation for adding device
const form = document.getElementById('addDeviceForm');
const formData = new FormData(form);
// Convert to JSON and submit
alert('Adding device...');
// Close modal
bootstrap.Modal.getInstance(document.getElementById('addDeviceModal')).hide();
}
function uploadPlaybook() {
// Implementation for uploading playbook
const form = document.getElementById('uploadPlaybookForm');
const formData = new FormData(form);
alert('Uploading playbook...');
// Close modal
bootstrap.Modal.getInstance(document.getElementById('uploadPlaybookModal')).hide();
}
</script>
{% endblock %}
@@ -0,0 +1,542 @@
{% extends "base.html" %}
{% block title %}Ansible Inventory — Server Monitoring{% endblock %}
{% block page_title %}Ansible Inventory{% endblock %}
{% block content %}
<div class="container-fluid">
<div id="alertArea"></div>
<!-- ══ TOP ROW: Inventory list | Group management ══════════════ -->
<div class="row g-3 mb-4">
<!-- ── Panel 1: All hosts currently in inventory ──────────────── -->
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<span>
<i class="fas fa-network-wired text-primary me-2"></i>
<strong>Inventory Hosts</strong>
{% set ns = namespace(total=0) %}
{% 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 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>
</button>
</div>
</div>
<!-- Raw YAML panel -->
<div id="rawPanel" class="d-none border-bottom">
<pre class="p-3 mb-0"
style="max-height:260px;overflow:auto;background:#1e1e1e;color:#d4d4d4;font-size:.78rem;">{{ inventory.raw_yaml | e }}</pre>
</div>
{% if inventory.groups %}
<div class="card-body p-0" style="max-height:480px;overflow-y:auto;">
{% for group_name, group in inventory.groups.items() %}
<!-- group label row -->
<div class="px-3 py-1 bg-light border-bottom d-flex align-items-center gap-2"
style="font-size:.75rem;">
<i class="fas fa-layer-group text-muted"></i>
<strong class="text-uppercase" style="letter-spacing:.04em;">{{ group_name }}</strong>
<span class="badge bg-light text-dark border">{{ group.hosts|length }}</span>
{% if group_name == 'monitoring_devices' %}
<span class="badge bg-info" style="font-size:.65rem;">default</span>
{% endif %}
</div>
<!-- host rows -->
{% for host in group.hosts %}
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom host-inv-row"
id="host-row-{{ group_name }}-{{ host.hostname }}"
data-hostname="{{ host.hostname }}"
data-group="{{ group_name }}">
<div>
<strong style="font-size:.88rem;">{{ host.hostname }}</strong>
<code class="ms-2 text-muted" style="font-size:.77rem;">{{ host.get('ansible_host','') }}</code>
</div>
<div class="d-flex align-items-center gap-2">
{% if host.get('ansible_connection') == 'local' %}
<span class="badge bg-secondary" style="font-size:.68rem;">local</span>
{% elif host.get('ansible_ssh_private_key_file') %}
<span class="badge bg-success" style="font-size:.68rem;">SSH key</span>
{% elif host.get('ansible_password') %}
<span class="badge bg-warning text-dark" style="font-size:.68rem;">password</span>
{% else %}
<span class="badge bg-light text-dark border" style="font-size:.68rem;"></span>
{% endif %}
<button class="btn btn-sm btn-outline-danger" style="padding:1px 6px;font-size:.72rem;"
onclick="removeHost('{{ group_name }}','{{ host.hostname }}')">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{% else %}
<div class="px-3 py-2 text-muted border-bottom" style="font-size:.82rem;">
No hosts in this group.
<a href="#" onclick="openAddHostModal('{{ group_name }}'); return false;">Add one</a>
</div>
{% endfor %}
{% endfor %}
</div>
{% 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. Click <strong>Sync IPs from DB</strong> to import all active devices.</p>
</div>
{% endif %}
</div>
</div>
<!-- ── Panel 2: Group management ──────────────────────────────── -->
<div class="col-lg-7">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<span>
<i class="fas fa-layer-group text-success me-2"></i>
<strong>Groups</strong>
<span class="badge bg-secondary ms-2">{{ inventory.groups | length }}</span>
</span>
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#createGroupModal">
<i class="fas fa-plus me-1"></i>New Group
</button>
</div>
{% if inventory.groups %}
<div class="card-body p-0">
<div class="accordion accordion-flush" id="groupAccordion">
{% for group_name, group in inventory.groups.items() %}
<div class="accordion-item" id="grp-panel-{{ group_name }}">
<h2 class="accordion-header">
<button class="accordion-button {% if not loop.first %}collapsed{% endif %} py-2"
type="button"
data-bs-toggle="collapse"
data-bs-target="#grp-body-{{ group_name }}">
<span class="me-2">
<i class="fas fa-layer-group me-2 text-primary"></i>
<strong>{{ group_name }}</strong>
</span>
<span class="badge bg-secondary me-2">{{ group.hosts|length }} host(s)</span>
{% if group_name == 'monitoring_devices' %}
<span class="badge bg-info" style="font-size:.68rem;">default</span>
{% endif %}
</button>
</h2>
<div id="grp-body-{{ group_name }}"
class="accordion-collapse collapse {% if loop.first %}show{% endif %}">
<div class="accordion-body p-2">
{% if group.hosts %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-2">
<thead class="table-light">
<tr>
<th>Hostname</th>
<th>IP</th>
<th>Auth</th>
<th class="text-end">Remove</th>
</tr>
</thead>
<tbody>
{% for host in group.hosts %}
<tr id="grptbl-{{ group_name }}-{{ host.hostname }}">
<td><strong>{{ host.hostname }}</strong></td>
<td><code style="font-size:.8rem;">{{ host.get('ansible_host','—') }}</code></td>
<td>
{% if host.get('ansible_connection') == 'local' %}
<span class="badge bg-secondary" style="font-size:.68rem;">local</span>
{% elif host.get('ansible_ssh_private_key_file') %}
<span class="badge bg-success" style="font-size:.68rem;">key</span>
{% elif host.get('ansible_password') %}
<span class="badge bg-warning text-dark" style="font-size:.68rem;">pw</span>
{% else %}
<span class="badge bg-light text-dark border" style="font-size:.68rem;"></span>
{% endif %}
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" style="padding:1px 6px;"
onclick="removeHost('{{ group_name }}','{{ host.hostname }}')">
<i class="fas fa-times"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-2" style="font-size:.82rem;">No hosts in this group.</p>
{% endif %}
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary"
onclick="openAddHostModal('{{ group_name }}')">
<i class="fas fa-plus me-1"></i>Add Host
</button>
{% if group_name != 'monitoring_devices' %}
<button class="btn btn-sm btn-outline-danger"
onclick="removeGroup('{{ group_name }}')">
<i class="fas fa-trash me-1"></i>Delete Group
</button>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="card-body text-center py-5 text-muted">
<i class="fas fa-folder-open fa-3x mb-3"></i>
<p class="mb-0">No groups yet. Click <strong>New Group</strong> to create one.</p>
</div>
{% endif %}
</div>
</div>
</div><!-- /top row -->
<!-- ══ BOTTOM: Available hosts from monitoring DB ════════════════ -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<span>
<i class="fas fa-desktop text-secondary me-2"></i>
<strong>Discovered Devices</strong>
<small class="text-muted ms-2">from monitoring database — not yet in inventory</small>
{% set avail = db_devices | selectattr('in_inventory','equalto',false) | list %}
<span class="badge bg-secondary ms-2">{{ avail | length }}</span>
</span>
</div>
{% set avail = db_devices | selectattr('in_inventory','equalto',false) | list %}
{% if avail %}
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Hostname</th>
<th>IP</th>
<th>Type</th>
<th>Status</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
{% for d in avail %}
<tr id="avail-row-{{ d.hostname }}">
<td><strong>{{ d.hostname }}</strong></td>
<td><code style="font-size:.8rem;">{{ d.device_ip }}</code></td>
<td>{{ d.device_type or '—' }}</td>
<td>
{% if d.status == 'active' %}
<span class="badge bg-success">Active</span>
{% elif d.status == 'inactive' %}
<span class="badge bg-warning text-dark">Inactive</span>
{% else %}
<span class="badge bg-secondary">{{ d.status }}</span>
{% endif %}
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary"
onclick="quickAdd('{{ d.hostname }}','{{ d.device_ip }}')">
<i class="fas fa-arrow-up me-1"></i>Add to Inventory
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% elif db_devices %}
<div class="card-body text-center py-4 text-muted">
<i class="fas fa-check-circle fa-2x mb-2 text-success"></i>
<p class="mb-0">All discovered devices are already in the inventory.</p>
</div>
{% else %}
<div class="card-body text-center py-4 text-muted">
<p class="mb-0">No devices discovered yet.</p>
</div>
{% endif %}
</div>
</div><!-- /container-fluid -->
<!-- ═══ Create Group Modal (with device picker) ══════════════════ -->
<div class="modal fade" id="createGroupModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-layer-group me-2"></i>Create New Group</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Group Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="newGroupName"
placeholder="e.g. webservers, rpi_devices"
pattern="[a-zA-Z0-9_\-]+" title="Letters, numbers, underscores and hyphens only">
</div>
<label class="form-label">Select hosts to add to this group</label>
<div class="border rounded p-2" style="max-height:280px;overflow-y:auto;" id="groupDevicePicker">
{% set all_inv_hosts = [] %}
{% for g in inventory.groups.values() %}
{% for h in g.hosts %}{% if all_inv_hosts.append(h.hostname) %}{% endif %}{% endfor %}
{% endfor %}
{% if all_inv_hosts %}
<div class="mb-2">
<small class="text-muted fw-bold">Already in inventory</small>
{% for group_name, group in inventory.groups.items() %}
{% for host in group.hosts %}
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="pick-inv-{{ host.hostname }}"
value="{{ host.hostname }}"
data-ip="{{ host.get('ansible_host','') }}"
name="groupHostPick">
<label class="form-check-label" for="pick-inv-{{ host.hostname }}">
{{ host.hostname }}
<code class="text-muted ms-1" style="font-size:.8rem;">{{ host.get('ansible_host','') }}</code>
<span class="badge bg-light text-dark border ms-1" style="font-size:.68rem;">{{ group_name }}</span>
</label>
</div>
{% endfor %}
{% endfor %}
</div>
{% endif %}
{% set avail2 = db_devices | selectattr('in_inventory','equalto',false) | list %}
{% if avail2 %}
<div>
<small class="text-muted fw-bold">Available (not yet in inventory)</small>
{% for d in avail2 %}
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="pick-avail-{{ d.hostname }}"
value="{{ d.hostname }}"
data-ip="{{ d.device_ip }}"
name="groupHostPick">
<label class="form-check-label" for="pick-avail-{{ d.hostname }}">
{{ d.hostname }}
<code class="text-muted ms-1" style="font-size:.8rem;">{{ d.device_ip }}</code>
{% if d.status == 'active' %}<span class="badge bg-success ms-1" style="font-size:.65rem;">active</span>{% endif %}
</label>
</div>
{% endfor %}
</div>
{% endif %}
{% if not all_inv_hosts and not avail2 %}
<p class="text-muted mb-0 text-center py-3">No devices available.</p>
{% endif %}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="createGroup()">Create Group</button>
</div>
</div>
</div>
</div>
<!-- ═══ Add Host to Group Modal ══════════════════════════════════ -->
<div class="modal fade" id="addHostModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-server me-2"></i>Add Host to:
<span id="addHostGroupLabel" class="text-primary ms-1"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="addHostGroup">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Hostname <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="addHostname" placeholder="e.g. rpi-desk-01">
</div>
<div class="col-md-6">
<label class="form-label">IP Address <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="addHostIP" placeholder="e.g. 192.168.1.50">
</div>
<div class="col-md-4">
<label class="form-label">SSH User</label>
<input type="text" class="form-control" id="addHostUser" value="pi">
</div>
<div class="col-md-4">
<label class="form-label">SSH Port</label>
<input type="number" class="form-control" id="addHostPort" value="22" min="1" max="65535">
</div>
<div class="col-md-4">
<label class="form-label">Authentication</label>
<select class="form-select" id="addHostAuth" onchange="togglePwField(this.value)">
<option value="key">SSH Key (recommended)</option>
<option value="password">Password</option>
</select>
</div>
<div class="col-12 d-none" id="pwField">
<label class="form-label">Password</label>
<input type="password" class="form-control" id="addHostPassword" autocomplete="new-password">
<div class="form-text text-warning">
<i class="fas fa-exclamation-triangle me-1"></i>Stored in plain text in inventory file.
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="addHost()">Add Host</button>
</div>
</div>
</div>
</div>
<script>
const API = '/api/ansible';
function showAlert(msg, type='success') {
const d = document.createElement('div');
d.className = `alert alert-${type} alert-dismissible fade show`;
d.innerHTML = `${msg} <button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
document.getElementById('alertArea').appendChild(d);
setTimeout(() => { try { d.remove(); } catch {} }, 6000);
}
function toggleRaw() {
document.getElementById('rawPanel').classList.toggle('d-none');
}
function togglePwField(val) {
document.getElementById('pwField').classList.toggle('d-none', val !== 'password');
}
/* ── Sync IPs from monitoring DB into inventory ── */
async function syncDevices() {
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-database me-1"></i>Sync IPs from DB';
}
}
/* ── Create group (with pre-selected hosts) ── */
async function createGroup() {
const name = document.getElementById('newGroupName').value.trim();
if (!name) { showAlert('Group name is required', 'warning'); return; }
// First create the group
let r = await fetch(`${API}/inventory/group/add`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({group_name: name})
});
let d = await r.json();
if (!d.success) { showAlert(d.error || 'Failed to create group', 'danger'); return; }
// Then add each checked host
const checks = document.querySelectorAll('input[name="groupHostPick"]:checked');
for (const cb of checks) {
const hostname = cb.value;
const ip = cb.dataset.ip;
await fetch(`${API}/inventory/host/add`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({group: name, hostname, ip, ssh_user:'pi', ssh_port:22, use_key:true})
});
}
bootstrap.Modal.getInstance(document.getElementById('createGroupModal')).hide();
showAlert(`<i class="fas fa-check-circle me-1"></i> Group "${name}" created with ${checks.length} host(s).`, 'success');
setTimeout(() => location.reload(), 1200);
}
/* ── Remove group ── */
async function removeGroup(groupName) {
if (!confirm(`Delete group "${groupName}" and remove all its hosts from the group?`)) return;
const r = await fetch(`${API}/inventory/group/remove`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({group_name: groupName})
});
const d = await r.json();
if (d.success) {
document.getElementById(`grp-panel-${groupName}`)?.remove();
showAlert(`<i class="fas fa-check-circle me-1"></i> ${d.message}`, 'success');
} else { showAlert(d.error || 'Failed', 'danger'); }
}
/* ── Open Add Host modal ── */
function openAddHostModal(groupName, hostname='', ip='') {
document.getElementById('addHostGroup').value = groupName;
document.getElementById('addHostGroupLabel').textContent = groupName;
document.getElementById('addHostname').value = hostname;
document.getElementById('addHostIP').value = ip;
document.getElementById('addHostUser').value = 'pi';
document.getElementById('addHostPort').value = 22;
document.getElementById('addHostAuth').value = 'key';
togglePwField('key');
new bootstrap.Modal(document.getElementById('addHostModal')).show();
}
/* ── Quick-add from discovered panel ── */
function quickAdd(hostname, ip) {
openAddHostModal('monitoring_devices', hostname, ip);
}
/* ── Add host ── */
async function addHost() {
const group = document.getElementById('addHostGroup').value;
const hostname = document.getElementById('addHostname').value.trim();
const ip = document.getElementById('addHostIP').value.trim();
const user = document.getElementById('addHostUser').value.trim() || 'pi';
const port = parseInt(document.getElementById('addHostPort').value) || 22;
const authType = document.getElementById('addHostAuth').value;
const password = document.getElementById('addHostPassword').value;
if (!hostname || !ip) { showAlert('Hostname and IP are required', 'warning'); return; }
const r = await fetch(`${API}/inventory/host/add`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({group, hostname, ip, ssh_user:user, ssh_port:port,
use_key: authType==='key',
password: authType==='password' ? password : null})
});
const d = await r.json();
if (d.success) {
bootstrap.Modal.getInstance(document.getElementById('addHostModal')).hide();
showAlert(`<i class="fas fa-check-circle me-1"></i> ${d.message}`, 'success');
setTimeout(() => location.reload(), 1000);
} else { showAlert(d.error || 'Failed to add host', 'danger'); }
}
/* ── Remove host ── */
async function removeHost(group, hostname) {
if (!confirm(`Remove "${hostname}" from group "${group}"?`)) return;
const r = await fetch(`${API}/inventory/host/remove`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({group, hostname})
});
const d = await r.json();
if (d.success) {
document.getElementById(`host-row-${group}-${hostname}`)?.remove();
document.getElementById(`grptbl-${group}-${hostname}`)?.remove();
showAlert(`<i class="fas fa-check-circle me-1"></i> ${d.message}`, 'success');
} else { showAlert(d.error || 'Failed', 'danger'); }
}
</script>
{% endblock %}
@@ -0,0 +1,526 @@
{% extends "base.html" %}
{% block title %}Execute Ansible Playbook - Server Monitoring{% endblock %}
{% block page_title %}Execute Ansible Playbook{% endblock %}
{% block extra_css %}
<style>
.playbook-card {
border: 2px solid #e9ecef; border-radius: 8px;
transition: all 0.2s; cursor: pointer;
}
.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; }
#execPopupNotice { display: none; }
.pulse-dot {
width: 10px; height: 10px; border-radius: 50%;
background: #4ec9b0; animation: pulse 1.2s infinite; display: inline-block;
}
.pulse-dot.done { background: #6a9955; animation: none; }
.pulse-dot.error { background: #f44747; animation: none; }
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:.3;} }
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category=='error' else category }} alert-dismissible fade show">
{{ message }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}{% endif %}
{% endwith %}
<form method="POST" id="executeForm">
<div class="row g-3 mb-3">
<!-- ── Playbook selection ──────────────────────────────────── -->
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="fas fa-book me-2"></i>Select Playbook</h5>
</div>
<div class="card-body" style="overflow-y:auto; max-height:480px;">
<h6 class="text-muted fw-bold mb-2 small text-uppercase">Built-in</h6>
<div class="card playbook-card mb-2" data-name="update_devices" onclick="selectPlaybook('update_devices')">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1 text-primary"><i class="fas fa-download me-1"></i>Update Devices</h6>
<p class="small text-muted mb-0">Update all packages on monitoring devices</p>
</div>
<span class="badge bg-success ms-2 flex-shrink-0">Built-in</span>
</div>
</div>
</div>
<div class="card playbook-card mb-2" data-name="restart_service" onclick="selectPlaybook('restart_service')">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1 text-success"><i class="fas fa-redo me-1"></i>Restart Service</h6>
<p class="small text-muted mb-0">Restart monitoring services on devices</p>
</div>
<span class="badge bg-success ms-2 flex-shrink-0">Built-in</span>
</div>
</div>
</div>
<div class="card playbook-card mb-3" data-name="system_health" onclick="selectPlaybook('system_health')">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1 text-info"><i class="fas fa-heartbeat me-1"></i>System Health</h6>
<p class="small text-muted mb-0">Check system health and monitoring status</p>
</div>
<span class="badge bg-success ms-2 flex-shrink-0">Built-in</span>
</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">
<option value="">— select custom playbook —</option>
</select>
<input type="hidden" name="playbook" id="selectedPlaybook">
</div>
</div>
</div>
<!-- ── Target selection ────────────────────────────────────── -->
<div class="col-lg-7">
<div class="card h-100">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-crosshairs me-2"></i>Select Target</h5>
<span id="targetCountBadge" class="badge bg-light text-dark"></span>
</div>
<div class="card-body p-3">
<!-- Mode pills -->
<ul class="nav nav-pills nav-fill mb-3">
<li class="nav-item">
<button type="button" class="nav-link active" id="pill-all" onclick="setTargetMode('all')">
<i class="fas fa-globe me-1"></i>All Hosts
</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link" id="pill-group" onclick="setTargetMode('group')">
<i class="fas fa-layer-group me-1"></i>By Group
</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link" id="pill-host" onclick="setTargetMode('host')">
<i class="fas fa-server me-1"></i>By Host
</button>
</li>
</ul>
<!-- All Hosts panel -->
<div id="panel-all">
{% set ns = namespace(total=0) %}
{% for g in inventory.groups.values() %}{% set ns.total = ns.total + g.hosts|length %}{% endfor %}
{% if ns.total > 0 %}
<div class="alert alert-info mb-0">
<i class="fas fa-info-circle me-2"></i>
Will run on <strong>all {{ ns.total }} host(s)</strong>
across <strong>{{ inventory.groups|length }} group(s)</strong>.
</div>
{% else %}
<div class="alert alert-warning mb-0">
<i class="fas fa-exclamation-triangle me-2"></i>
No hosts in inventory yet.
<a href="{{ url_for('ansible_web.devices') }}">Add devices to inventory</a> first.
</div>
{% endif %}
</div>
<!-- By Group panel -->
<div id="panel-group" class="d-none" style="max-height:300px;overflow-y:auto;">
{% if inventory.groups %}
{% for group_name, group in inventory.groups.items() %}
<div class="form-check border rounded p-2 mb-2">
<input class="form-check-input group-cb" type="checkbox"
id="grp-{{ group_name }}" value="{{ group_name }}"
data-count="{{ group.hosts|length }}"
onchange="updateTargetCount()">
<label class="form-check-label w-100" for="grp-{{ group_name }}">
<div class="d-flex justify-content-between align-items-center">
<strong>{{ group_name }}</strong>
<span class="badge bg-secondary">{{ group.hosts|length }} host(s)</span>
</div>
<small class="text-muted">
{% for h in group.hosts[:3] %}{{ h.hostname }}{% if not loop.last %}, {% endif %}{% endfor %}{% if group.hosts|length > 3 %}&nbsp;+{{ group.hosts|length - 3 }} more{% endif %}
</small>
</label>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center py-3">No groups in inventory.</p>
{% endif %}
</div>
<!-- By Host panel -->
<div id="panel-host" class="d-none" style="max-height:300px;overflow-y:auto;">
{% if all_inv_hosts %}
<div class="mb-2 d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="checkAllHosts(true)">
<i class="fas fa-check-double me-1"></i>Select All
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="checkAllHosts(false)">Clear</button>
</div>
{% for h in all_inv_hosts %}
<div class="form-check border-bottom py-2">
<input class="form-check-input host-cb" type="checkbox"
id="invhost-{{ h.hostname }}" value="{{ h.hostname }}"
onchange="updateTargetCount()">
<label class="form-check-label d-flex justify-content-between w-100" for="invhost-{{ h.hostname }}">
<strong>{{ h.hostname }}</strong>
<code class="text-muted" style="font-size:.8rem;">{{ h.ip }}</code>
</label>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center py-3">No hosts in inventory yet.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div><!-- /top row -->
<!-- ── Advanced Options ──────────────────────────────────────── -->
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-header bg-info text-white">
<h6 class="mb-0">
<i class="fas fa-cogs me-1"></i>Advanced Options
<button type="button" class="btn btn-outline-light btn-sm ms-2"
data-bs-toggle="collapse" data-bs-target="#advancedOptions">
<i class="fas fa-chevron-down"></i>
</button>
</h6>
</div>
<div class="collapse" id="advancedOptions">
<div class="card-body">
<div class="row">
<div class="col-md-4">
<h6>Execution Settings</h6>
<div class="mb-3">
<label class="form-label">Priority</label>
<select class="form-select" name="priority">
<option value="1">1 — Low</option>
<option value="3">3 — Below Normal</option>
<option value="5" selected>5 — Normal</option>
<option value="7">7 — High</option>
<option value="10">10 — Critical</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Max Retries</label>
<select class="form-select" name="max_retries">
<option value="0" selected>0 — No retries</option>
<option value="1">1 retry</option>
<option value="2">2 retries</option>
<option value="3">3 retries</option>
</select>
</div>
<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>
<textarea class="form-control json-editor" name="extra_vars" id="extraVars" rows="6"
placeholder='{"variable": "value"}'></textarea>
<div class="form-text">Optional variables passed to playbook</div>
</div>
<div class="col-md-4">
<h6>Common Variables</h6>
<ul class="list-unstyled small">
<li><code>timeout: 600</code></li>
<li><code>reboot_timeout: 900</code></li>
<li><code>become_user: "root"</code></li>
<li><code>force: true</code></li>
</ul>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="addCommonVars()">
<i class="fas fa-plus me-1"></i>Add Common Vars
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── Summary bar + Execute button ─────────────────────────── -->
<div class="card mb-3">
<div class="card-body py-3">
<div class="row align-items-center">
<div class="col-md-3">
<small class="text-muted d-block">Playbook</small>
<strong id="summaryPlaybook">None selected</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">Target</small>
<strong id="summaryDevices">None selected</strong>
</div>
<div class="col-md-3">
<small class="text-muted d-block">Est. Duration</small>
<strong id="estimatedDuration">Unknown</strong>
</div>
<div class="col-md-2 text-end">
<button type="submit" class="btn btn-primary btn-lg w-100" id="executeButton">
<i class="fas fa-play me-1"></i>Execute
</button>
</div>
</div>
</div>
</div>
<!-- Hidden host inputs populated by JS before submit -->
<div id="hostsContainer"></div>
</form>
<!-- ── Popup launched notice ─────────────────────────────────── -->
<div class="row mt-3" id="execPopupNotice">
<div class="col-12">
<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>
</div><!-- /container-fluid -->
<script>
// ── State ────────────────────────────────────────────────────────────
let selectedPlaybook = null;
let targetMode = 'all';
let executionId = null;
// ── Playbook selection ───────────────────────────────────────────────
function selectPlaybook(name) {
document.querySelectorAll('.playbook-card').forEach(c => c.classList.remove('selected'));
document.getElementById('customPlaybook').value = '';
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();
}
document.getElementById('customPlaybook').addEventListener('change', function() {
if (this.value) {
document.querySelectorAll('.playbook-card').forEach(c => c.classList.remove('selected'));
selectedPlaybook = this.value;
document.getElementById('selectedPlaybook').value = this.value;
updateSummary();
}
});
// ── Target mode ──────────────────────────────────────────────────────
function setTargetMode(mode) {
targetMode = mode;
['all','group','host'].forEach(m => {
document.getElementById(`panel-${m}`).classList.toggle('d-none', m !== mode);
document.getElementById(`pill-${m}`).classList.toggle('active', m === mode);
});
updateTargetCount();
}
function updateTargetCount() {
let count = 0, label = '';
if (targetMode === 'all') {
count = document.querySelectorAll('.host-cb').length;
label = count > 0 ? `All — ${count} host(s)` : 'No hosts in inventory';
} else if (targetMode === 'group') {
const grps = document.querySelectorAll('.group-cb:checked');
grps.forEach(cb => count += parseInt(cb.dataset.count || '0'));
label = grps.length > 0 ? `${grps.length} group(s) / ${count} host(s)` : 'None selected';
} else {
count = document.querySelectorAll('.host-cb:checked').length;
label = count > 0 ? `${count} host(s) selected` : 'None selected';
}
document.getElementById('targetCountBadge').textContent = label;
updateSummary();
}
function checkAllHosts(check) {
document.querySelectorAll('.host-cb').forEach(cb => cb.checked = check);
updateTargetCount();
}
function getTargetCount() {
if (targetMode === 'all') return document.querySelectorAll('.host-cb').length;
if (targetMode === 'group') {
let c = 0;
document.querySelectorAll('.group-cb:checked').forEach(cb => c += parseInt(cb.dataset.count || '0'));
return c;
}
return document.querySelectorAll('.host-cb:checked').length;
}
// Populate hidden <input name="hosts"> elements before submit
function buildHiddenInputs() {
const container = document.getElementById('hostsContainer');
container.innerHTML = '';
const add = v => {
const inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'hosts'; inp.value = v;
container.appendChild(inp);
};
if (targetMode === 'all') {
document.querySelectorAll('.host-cb').forEach(cb => add(cb.value));
if (container.children.length === 0) add('all'); // fallback if inventory empty
} else if (targetMode === 'group') {
document.querySelectorAll('.group-cb:checked').forEach(cb => add(cb.value));
} else {
document.querySelectorAll('.host-cb:checked').forEach(cb => add(cb.value));
}
}
// ── Summary bar ──────────────────────────────────────────────────────
function updateSummary() {
document.getElementById('summaryPlaybook').textContent = selectedPlaybook || 'None selected';
const count = getTargetCount();
const sd = document.getElementById('summaryDevices');
if (targetMode === 'all') {
sd.textContent = count > 0 ? `All hosts (${count})` : 'No hosts in inventory';
} else if (targetMode === 'group') {
const g = document.querySelectorAll('.group-cb:checked').length;
sd.textContent = g > 0 ? `${g} group(s), ~${count} host(s)` : 'None selected';
} else {
sd.textContent = count > 0 ? `${count} host(s) selected` : 'None selected';
}
const dur = document.getElementById('estimatedDuration');
if (selectedPlaybook && count > 0) {
if (selectedPlaybook === 'update_devices')
dur.textContent = `${Math.max(5, count * 2)}${count * 10} min`;
else if (selectedPlaybook === 'restart_service')
dur.textContent = `${count}${count * 2} min`;
else dur.textContent = 'Varies';
} else { dur.textContent = 'Unknown'; }
}
function addCommonVars() {
const el = document.getElementById('extraVars');
const common = {"timeout": 600, "reboot_timeout": 900, "become_user": "root"};
try {
let cur = el.value.trim() ? JSON.parse(el.value) : {};
Object.assign(cur, common);
el.value = JSON.stringify(cur, null, 2);
} catch { el.value = JSON.stringify(common, null, 2); }
}
// ── Form submission ──────────────────────────────────────────────────
document.getElementById('executeForm').addEventListener('submit', function(e) {
e.preventDefault();
if (!selectedPlaybook) { alert('Please select a playbook'); return; }
const count = getTargetCount();
if (count === 0) { alert('Please select at least one host or group'); return; }
const extraRaw = document.getElementById('extraVars').value.trim();
if (extraRaw) {
try { JSON.parse(extraRaw); }
catch { alert('Invalid JSON in Extra Variables'); return; }
}
const grpCount = document.querySelectorAll('.group-cb:checked').length;
const targetDesc = targetMode === 'all' ? `all hosts (${count})` :
targetMode === 'group' ? `${grpCount} group(s)` :
`${count} host(s)`;
if (!confirm(`Execute "${selectedPlaybook}" on ${targetDesc}?\nThis cannot be undone.`)) return;
buildHiddenInputs();
const btn = document.getElementById('executeButton');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting…';
fetch(this.action, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: new FormData(this),
})
.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;
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 {
alert('Execution failed: ' + (data.error || 'Unknown error'));
}
})
.catch(err => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play me-1"></i>Execute';
alert('Network error: ' + err);
});
});
// ── Initialize ───────────────────────────────────────────────────────
updateTargetCount();
updateSummary();
{% if preselect_playbook %}
selectPlaybook('{{ preselect_playbook }}');
{% endif %}
</script>
{% endblock %}
@@ -0,0 +1,311 @@
{% extends "base.html" %}
{% block title %}Execution History - Server Monitoring{% endblock %}
{% block page_title %}Ansible Execution History{% endblock %}
{% block extra_css %}
<style>
.execution-card {
border: none;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 1rem;
transition: all 0.3s ease;
}
.execution-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.status-running {
background: linear-gradient(45deg, #007bff, #0056b3);
}
.status-completed {
background: linear-gradient(45deg, #28a745, #1e7e34);
}
.status-failed {
background: linear-gradient(45deg, #dc3545, #bd2130);
}
.status-queued {
background: linear-gradient(45deg, #6c757d, #545b62);
}
.execution-details {
background-color: #f8f9fa;
border-radius: 8px;
padding: 1rem;
}
.host-result {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 15px;
font-size: 0.8rem;
margin: 0.1rem;
}
.host-success { background-color: #d4edda; color: #155724; }
.host-failed { background-color: #f8d7da; color: #721c24; }
.host-unreachable { background-color: #f8d7da; color: #721c24; }
.host-skipped { background-color: #fff3cd; color: #856404; }
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Page Header -->
<div class="row mb-4">
<div class="col-md-8">
<h4><i class="fas fa-history"></i> Ansible Execution History</h4>
<small class="text-muted">Track and monitor all playbook executions</small>
</div>
<div class="col-md-4 text-end">
<a href="{{ url_for('ansible_web.execute') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> New Execution
</a>
<button class="btn btn-outline-secondary ms-2" onclick="refreshPage()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
</div>
<!-- Execution Statistics -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-primary">{{ executions|length }}</h3>
<p class="mb-0">Total Executions</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-success">{{ executions|selectattr('status', 'equalto', 'completed')|list|length }}</h3>
<p class="mb-0">Completed</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-info">{{ executions|selectattr('status', 'equalto', 'running')|list|length }}</h3>
<p class="mb-0">Running</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-danger">{{ executions|selectattr('status', 'equalto', 'failed')|list|length }}</h3>
<p class="mb-0">Failed</p>
</div>
</div>
</div>
</div>
<!-- Execution List -->
<div class="row">
<div class="col-12">
{% if executions %}
{% for execution in executions %}
<div class="execution-card card">
<div class="card-header status-{{ execution.status }} text-white d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-0">
<i class="fas fa-play"></i> {{ execution.playbook_name }}
{% if execution.status == 'running' %}
<span class="spinner-border spinner-border-sm ms-2"></span>
{% endif %}
</h6>
<small>Execution ID: {{ execution.execution_id }}</small>
</div>
<div class="text-end">
<span class="badge bg-light text-dark">{{ execution.status|title }}</span>
{% if execution.priority and execution.priority != 5 %}
<span class="badge bg-warning text-dark">Priority: {{ execution.priority }}</span>
{% endif %}
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="execution-details">
<h6><i class="fas fa-info-circle"></i> Execution Details</h6>
<div class="row">
<div class="col-6">
<small class="text-muted">Started:</small><br>
{{ execution.started_at | local_dt if execution.started_at else 'Queued' }}
</div>
<div class="col-6">
<small class="text-muted">Duration:</small><br>
{% if execution.duration %}
{{ execution.duration_formatted }}
{% elif execution.status == 'running' and execution.started_at %}
<span id="duration-{{ execution.id }}">Calculating...</span>
{% else %}
-
{% endif %}
</div>
</div>
<div class="row mt-2">
<div class="col-6">
<small class="text-muted">User:</small><br>
{{ execution.execution_user or 'System' }}
</div>
<div class="col-6">
<small class="text-muted">Target Hosts:</small><br>
{{ execution.total_hosts or 0 }}
</div>
</div>
</div>
</div>
<div class="col-md-6">
{% if execution.status in ['completed', 'failed'] %}
<div class="execution-details">
<h6><i class="fas fa-chart-bar"></i> Results Summary</h6>
<div class="mb-2">
{% if execution.successful_hosts > 0 %}
<span class="host-result host-success">✓ {{ execution.successful_hosts }} successful</span>
{% endif %}
{% if execution.failed_hosts > 0 %}
<span class="host-result host-failed">✗ {{ execution.failed_hosts }} failed</span>
{% endif %}
{% if execution.unreachable_hosts > 0 %}
<span class="host-result host-unreachable">⚠ {{ execution.unreachable_hosts }} unreachable</span>
{% endif %}
{% if execution.skipped_hosts > 0 %}
<span class="host-result host-skipped">⊝ {{ execution.skipped_hosts }} skipped</span>
{% endif %}
</div>
{% if execution.summary_message %}
<small class="text-muted">{{ execution.summary_message }}</small>
{% endif %}
</div>
{% elif execution.status == 'running' %}
<div class="execution-details">
<h6><i class="fas fa-spinner fa-spin"></i> In Progress</h6>
<div class="progress mb-2">
<div class="progress-bar progress-bar-striped progress-bar-animated"
style="width: 100%"></div>
</div>
<small class="text-muted">Execution is currently running...</small>
</div>
{% else %}
<div class="execution-details">
<h6><i class="fas fa-clock"></i> Queued</h6>
{% if execution.queue_position > 0 %}
<p class="mb-0">Position in queue: {{ execution.queue_position }}</p>
{% endif %}
<small class="text-muted">Waiting for execution...</small>
</div>
{% endif %}
</div>
</div>
<!-- Action Buttons -->
<div class="mt-3 text-end">
<a href="{{ url_for('ansible_web.execution_details', execution_id=execution.execution_id) }}"
class="btn btn-outline-primary btn-sm">
<i class="fas fa-eye"></i> View Details
</a>
{% if execution.status == 'running' %}
<button class="btn btn-outline-warning btn-sm ms-1"
onclick="cancelExecution('{{ execution.execution_id }}')">
<i class="fas fa-stop"></i> Cancel
</button>
{% elif execution.status == 'failed' and execution.retry_count < execution.max_retries %}
<button class="btn btn-outline-success btn-sm ms-1"
onclick="retryExecution('{{ execution.execution_id }}')">
<i class="fas fa-redo"></i> Retry
</button>
{% endif %}
{% if execution.ansible_log_file %}
<a href="{{ url_for('ansible_web.download_log', execution_id=execution.execution_id) }}"
class="btn btn-outline-secondary btn-sm ms-1">
<i class="fas fa-download"></i> Download Log
</a>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="card">
<div class="card-body text-center">
<i class="fas fa-info-circle fa-3x text-muted mb-3"></i>
<h5>No Executions Found</h5>
<p class="text-muted">No playbook executions have been run yet.</p>
<a href="{{ url_for('ansible_web.execute') }}" class="btn btn-primary">
<i class="fas fa-play"></i> Run Your First Playbook
</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<script>
function refreshPage() {
location.reload();
}
function cancelExecution(executionId) {
if (confirm('Are you sure you want to cancel this execution?')) {
fetch(`/api/ansible/executions/${executionId}/cancel`, { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Failed to cancel execution: ' + data.error);
}
});
}
}
function retryExecution(executionId) {
if (confirm('Are you sure you want to retry this execution?')) {
fetch(`/api/ansible/executions/${executionId}/retry`, { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Execution queued for retry');
location.reload();
} else {
alert('Failed to retry execution: ' + data.error);
}
});
}
}
// Auto-refresh running executions every 30 seconds
setInterval(() => {
const runningExecutions = document.querySelectorAll('.status-running');
if (runningExecutions.length > 0) {
location.reload();
}
}, 30000);
// Update running durations
function updateRunningDurations() {
document.querySelectorAll('[id^="duration-"]').forEach(element => {
const executionId = element.id.replace('duration-', '');
// This would need to be implemented to calculate current duration
// For now, show a simple indicator
element.textContent = 'Running...';
});
}
setInterval(updateRunningDurations, 1000);
</script>
{% endblock %}
@@ -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 %}
@@ -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>
@@ -0,0 +1,685 @@
{% extends "base.html" %}
{% block title %}Ansible Playbook Management - Server Monitoring{% endblock %}
{% block page_title %}Ansible Playbook Management{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.15/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.15/theme/monokai.min.css">
<style>
.playbook-item {
cursor: pointer;
transition: all 0.2s;
}
.playbook-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.playbook-item.selected {
border-color: #007bff;
background-color: #f8f9ff;
}
.code-editor-area {
min-height: 400px;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
}
.CodeMirror {
height: 400px;
border-radius: 0.375rem;
}
.playbook-actions {
position: sticky;
top: 0;
z-index: 10;
background: white;
border-bottom: 1px solid #dee2e6;
padding: 1rem;
margin: -1rem -1rem 1rem -1rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Header Actions -->
<div class="row mb-4">
<div class="col-md-8">
<h4><i class="fas fa-book"></i> Ansible Playbook Management</h4>
<small class="text-muted">Create, edit, and manage your Ansible automation playbooks</small>
</div>
<div class="col-md-4 text-end">
<button class="btn btn-success me-2" onclick="createNewPlaybook()">
<i class="fas fa-plus"></i> New Playbook
</button>
<button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#uploadPlaybookModal">
<i class="fas fa-upload"></i> Import File
</button>
<button class="btn btn-outline-secondary" onclick="refreshPlaybooks()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
</div>
<!-- Main Layout -->
<div class="row">
<!-- Playbook List Sidebar -->
<div class="col-lg-4">
<!-- Playbook Statistics -->
<div class="row mb-3">
<div class="col-6">
<div class="card bg-primary text-white text-center">
<div class="card-body py-2">
<h6 class="mb-0">Custom</h6>
<h4 class="mb-0">{{ playbooks | length }}</h4>
</div>
</div>
</div>
<div class="col-6">
<div class="card bg-success text-white text-center">
<div class="card-body py-2">
<h6 class="mb-0">Built-in</h6>
<h4 class="mb-0">{{ builtin_playbooks | length }}</h4>
</div>
</div>
</div>
</div>
<!-- Built-in Playbooks Section -->
{% if builtin_playbooks %}
<div class="card mb-3">
<div class="card-header py-2">
<h6 class="mb-0"><i class="fas fa-star text-warning"></i> Built-in Playbooks</h6>
</div>
<div class="card-body p-2">
{% for playbook in builtin_playbooks %}
<div class="playbook-item card mb-2 border-success" onclick="loadBuiltinPlaybook('{{ playbook.name }}')">
<div class="card-body py-2">
<h6 class="card-title mb-1">{{ playbook.name }}</h6>
<p class="card-text small text-muted mb-1">{{ playbook.description }}</p>
<div class="d-flex justify-content-between align-items-center">
<span class="badge bg-success">Built-in</span>
<a href="{{ url_for('ansible_web.execute') }}?playbook={{ playbook.name }}"
class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation()">
<i class="fas fa-play me-1"></i>Execute
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Custom Playbooks Section -->
<div class="card">
<div class="card-header py-2">
<h6 class="mb-0"><i class="fas fa-file-code"></i> Custom Playbooks</h6>
</div>
<div class="card-body p-2">
{% if playbooks %}
{% for playbook in playbooks %}
<div class="playbook-item card mb-2" onclick="loadPlaybook('{{ playbook.name }}', '{{ playbook.path }}')">
<div class="card-body py-2">
<h6 class="card-title mb-1">{{ playbook.name }}</h6>
<p class="card-text small text-muted mb-1">{{ playbook.filename }}</p>
<div class="d-flex justify-content-between align-items-center">
<span class="badge bg-info">Custom</span>
<div class="btn-group" role="group">
<a href="{{ url_for('ansible_web.execute') }}?playbook={{ playbook.name }}"
class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation()">
<i class="fas fa-play"></i>
</a>
<button class="btn btn-sm btn-outline-danger" onclick="event.stopPropagation(); deletePlaybook('{{ playbook.name }}')">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-3">
<i class="fas fa-folder-open fa-2x text-muted mb-2"></i>
<p class="text-muted mb-0">No custom playbooks</p>
<small class="text-muted">Create or import one to get started</small>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Code Editor Area -->
<div class="col-lg-8">
<div class="card h-100">
<div class="playbook-actions">
<div class="row align-items-center">
<div class="col">
<h6 class="mb-0" id="currentPlaybookTitle">Select a playbook to view/edit</h6>
<small class="text-muted" id="currentPlaybookInfo">Choose from the list on the left</small>
</div>
<div class="col-auto">
<div id="editorActions" style="display: none;">
<button class="btn btn-success btn-sm me-2" onclick="savePlaybook()">
<i class="fas fa-save"></i> Save
</button>
<button class="btn btn-outline-secondary btn-sm me-2" onclick="toggleEditMode()">
<i class="fas fa-edit"></i> <span id="editToggleText">Edit</span>
</button>
<button class="btn btn-primary btn-sm me-2" onclick="executeCurrentPlaybook()">
<i class="fas fa-play"></i> Execute
</button>
<button class="btn btn-outline-warning btn-sm" onclick="validatePlaybook()">
<i class="fas fa-check"></i> Validate
</button>
</div>
</div>
</div>
</div>
<div class="card-body p-0">
<!-- Welcome Message (shown when no playbook selected) -->
<div id="welcomeMessage" class="text-center p-5">
<i class="fas fa-book-open fa-4x text-muted mb-3"></i>
<h4 class="text-muted">Ansible Playbook Editor</h4>
<p class="text-muted mb-4">Select a playbook from the left panel to view or edit its content</p>
<div class="d-flex justify-content-center gap-2">
<button class="btn btn-primary" onclick="createNewPlaybook()">
<i class="fas fa-plus"></i> Create New Playbook
</button>
<button class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#uploadPlaybookModal">
<i class="fas fa-upload"></i> Import Playbook File
</button>
</div>
</div>
<!-- Code Editor -->
<div id="codeEditorContainer" style="display: none;">
<textarea id="playbookEditor"></textarea>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Upload Playbook Modal -->
<div class="modal fade" id="uploadPlaybookModal" tabindex="-1" aria-labelledby="uploadPlaybookModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="uploadPlaybookModalLabel">Upload Playbook</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{{ url_for('ansible_web.upload_playbook') }}" method="post" enctype="multipart/form-data">
<div class="modal-body">
<div class="mb-3">
<label for="playbookFile" class="form-label">Select Playbook File (.yml or .yaml)</label>
<input type="file" class="form-control" id="playbookFile" name="playbook_file"
accept=".yml,.yaml" required>
</div>
<div class="mb-3">
<label for="playbookName" class="form-label">Playbook Name (optional)</label>
<input type="text" class="form-control" id="playbookName" name="playbook_name"
placeholder="Leave empty to use filename">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Upload</button>
</div>
</form>
</div>
</div>
</div>
<!-- View Playbook Modal -->
<div class="modal fade" id="viewPlaybookModal" tabindex="-1" aria-labelledby="viewPlaybookModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="viewPlaybookModalLabel">Playbook Content</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<pre id="playbookContent" style="max-height: 400px; overflow-y: auto; background-color: #f8f9fa; padding: 15px; border-radius: 5px;"></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Create/Edit Playbook Modal -->
<div class="modal fade" id="createPlaybookModal" tabindex="-1" aria-labelledby="createPlaybookModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createPlaybookModalLabel">Create New Playbook</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newPlaybookName" class="form-label">Playbook Name</label>
<input type="text" class="form-control" id="newPlaybookName" placeholder="my_playbook" required>
<div class="form-text">Name should be lowercase, use underscores instead of spaces</div>
</div>
<div class="mb-3">
<label for="newPlaybookDescription" class="form-label">Description (optional)</label>
<input type="text" class="form-control" id="newPlaybookDescription" placeholder="Brief description of what this playbook does">
</div>
<div class="mb-3">
<label class="form-label">Template</label>
<div>
<div class="form-check">
<input class="form-check-input" type="radio" name="playbookTemplate" id="templateBlank" value="blank" checked>
<label class="form-check-label" for="templateBlank">
Blank Playbook
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="playbookTemplate" id="templateBasic" value="basic">
<label class="form-check-label" for="templateBasic">
Basic System Update Template
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="playbookTemplate" id="templateService" value="service">
<label class="form-check-label" for="templateService">
Service Management Template
</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="createPlaybookFromModal()">Create Playbook</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.15/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.15/mode/yaml/yaml.min.js"></script>
<script>
let codeEditor = null;
let isEditMode = false;
let currentPlaybook = null;
let isNewPlaybook = false;
// Initialize CodeMirror when document is ready
document.addEventListener('DOMContentLoaded', function() {
initializeCodeEditor();
});
function initializeCodeEditor() {
codeEditor = CodeMirror.fromTextArea(document.getElementById('playbookEditor'), {
lineNumbers: true,
mode: 'yaml',
theme: 'monokai',
indentUnit: 2,
tabSize: 2,
indentWithTabs: false,
readOnly: true, // Start in read-only mode
lineWrapping: true
});
}
function createNewPlaybook() {
const modal = new bootstrap.Modal(document.getElementById('createPlaybookModal'));
modal.show();
}
function createPlaybookFromModal() {
const name = document.getElementById('newPlaybookName').value.trim();
const description = document.getElementById('newPlaybookDescription').value.trim();
const template = document.querySelector('input[name="playbookTemplate"]:checked').value;
if (!name) {
alert('Please enter a playbook name');
return;
}
// Generate playbook content based on template
let content = '';
switch(template) {
case 'basic':
content = '---\n' +
'- name: ' + (description || 'Basic System Update') + '\n' +
' hosts: monitoring_devices\n' +
' become: yes\n' +
' vars:\n' +
' update_cache: yes\n' +
' upgrade_packages: yes\n' +
' \n' +
' tasks:\n' +
' - name: Update package cache\n' +
' apt:\n' +
' update_cache: "\\{\\{ update_cache \\}\\}"\n' +
' when: ansible_os_family == "Debian"\n' +
' \n' +
' - name: Upgrade all packages\n' +
' apt:\n' +
' upgrade: dist\n' +
' when: upgrade_packages and ansible_os_family == "Debian"\n' +
' \n' +
' - name: Remove unnecessary packages\n' +
' apt:\n' +
' autoremove: yes\n' +
' when: ansible_os_family == "Debian"\n' +
' \n' +
' - name: Display completion message\n' +
' debug:\n' +
' msg: "System update completed successfully"';
break;
case 'service':
content = '---\n' +
'- name: ' + (description || 'Service Management') + '\n' +
' hosts: monitoring_devices\n' +
' become: yes\n' +
' vars:\n' +
' service_name: "your_service_here"\n' +
' service_action: "restarted" # started, stopped, restarted, reloaded\n' +
' \n' +
' tasks:\n' +
' - name: Manage service\n' +
' systemd:\n' +
' name: "\\{\\{ service_name \\}\\}"\n' +
' state: "\\{\\{ service_action \\}\\}"\n' +
' enabled: yes\n' +
' register: service_result\n' +
' \n' +
' - name: Display service status\n' +
' debug:\n' +
' msg: "Service \\{\\{ service_name \\}\\} is \\{\\{ service_result.status.ActiveState \\}\\}"';
break;
default:
content = '---\n' +
'- name: ' + (description || name) + '\n' +
' hosts: monitoring_devices\n' +
' become: yes\n' +
' \n' +
' tasks:\n' +
' - name: Your task here\n' +
' debug:\n' +
' msg: "Hello from ' + name + ' playbook!"';
}
// Close modal
bootstrap.Modal.getInstance(document.getElementById('createPlaybookModal')).hide();
// Load into editor as new playbook
currentPlaybook = {name: name, isNew: true};
isNewPlaybook = true;
loadPlaybookIntoEditor(name, content, true);
}
function loadPlaybook(name, path) {
// Clear selection
clearPlaybookSelection();
// Mark as selected
event.currentTarget.classList.add('selected');
fetch(`/ansible/playbook/content?path=${encodeURIComponent(path)}`)
.then(response => response.text())
.then(content => {
currentPlaybook = {name: name, path: path};
isNewPlaybook = false;
loadPlaybookIntoEditor(name, content, false);
})
.catch(error => {
alert('Error loading playbook: ' + error);
});
}
function loadBuiltinPlaybook(name) {
// Clear selection
clearPlaybookSelection();
// Mark as selected
event.currentTarget.classList.add('selected');
// Generate built-in playbook content
let content = generateBuiltinPlaybookContent(name);
currentPlaybook = {name: name, builtin: true};
isNewPlaybook = false;
loadPlaybookIntoEditor(name, content, false);
}
function loadPlaybookIntoEditor(name, content, editMode = false) {
// Update UI
document.getElementById('welcomeMessage').style.display = 'none';
document.getElementById('codeEditorContainer').style.display = 'block';
document.getElementById('editorActions').style.display = 'block';
// Update title
document.getElementById('currentPlaybookTitle').textContent = name;
document.getElementById('currentPlaybookInfo').textContent =
isNewPlaybook ? 'New playbook - Remember to save' :
(currentPlaybook.builtin ? 'Built-in playbook (read-only)' : 'Custom playbook');
// Set content
codeEditor.setValue(content);
// Set edit mode
isEditMode = editMode || isNewPlaybook;
updateEditMode();
// Refresh editor
setTimeout(() => codeEditor.refresh(), 100);
}
function generateBuiltinPlaybookContent(name) {
switch(name) {
case 'update_devices':
return `---
- name: Update all monitoring devices
hosts: monitoring_devices
become: yes
tasks:
- name: Update package cache
apt:
update_cache: yes
when: ansible_os_family == "Debian"
- name: Upgrade packages
apt:
upgrade: dist
when: ansible_os_family == "Debian"
- name: Remove unnecessary packages
apt:
autoremove: yes
when: ansible_os_family == "Debian"`;
case 'restart_service':
return `---
- name: Restart monitoring services
hosts: monitoring_devices
become: yes
tasks:
- name: Restart monitoring service
systemd:
name: prezenta_monitor
state: restarted
ignore_errors: yes`;
case 'system_health':
return `---
- name: Check system health
hosts: monitoring_devices
become: yes
tasks:
- name: Check disk usage
command: df -h
register: disk_usage
- name: Check memory usage
command: free -m
register: memory_usage
- name: Display disk usage
debug:
var: disk_usage.stdout_lines
- name: Display memory usage
debug:
var: memory_usage.stdout_lines`;
default:
return `---
- name: ${name}
hosts: monitoring_devices
become: yes
tasks:
- name: Default task
debug:
msg: "Built-in playbook: ${name}"`;
}
}
function clearPlaybookSelection() {
document.querySelectorAll('.playbook-item').forEach(item => {
item.classList.remove('selected');
});
}
function toggleEditMode() {
if (currentPlaybook && currentPlaybook.builtin) {
alert('Built-in playbooks cannot be edited. Create a copy if you need to modify it.');
return;
}
isEditMode = !isEditMode;
updateEditMode();
}
function updateEditMode() {
codeEditor.setOption('readOnly', !isEditMode);
document.getElementById('editToggleText').textContent = isEditMode ? 'View' : 'Edit';
// Update editor theme
codeEditor.setOption('theme', isEditMode ? 'default' : 'monokai');
}
function savePlaybook() {
if (!currentPlaybook) {
alert('No playbook loaded');
return;
}
if (currentPlaybook.builtin) {
alert('Built-in playbooks cannot be modified');
return;
}
const content = codeEditor.getValue();
const data = {
name: currentPlaybook.name,
content: content,
is_new: isNewPlaybook
};
fetch('/ansible/playbook/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert('Playbook saved successfully!');
isNewPlaybook = false;
document.getElementById('currentPlaybookInfo').textContent = 'Custom playbook';
// Optionally refresh the page to update the playbook list
setTimeout(() => location.reload(), 1000);
} else {
alert('Error saving playbook: ' + result.error);
}
})
.catch(error => {
alert('Error saving playbook: ' + error);
});
}
function executeCurrentPlaybook() {
if (!currentPlaybook) {
alert('No playbook selected');
return;
}
window.location.href = `{{ url_for('ansible_web.execute') }}?playbook=${encodeURIComponent(currentPlaybook.name)}`;
}
function validatePlaybook() {
if (!codeEditor) {
alert('No playbook loaded');
return;
}
const content = codeEditor.getValue();
fetch('/ansible/playbook/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({content: content})
})
.then(response => response.json())
.then(result => {
if (result.valid) {
alert('Playbook is valid! ✅');
} else {
alert('Playbook validation failed: ' + result.error);
}
})
.catch(error => {
alert('Error validating playbook: ' + error);
});
}
function refreshPlaybooks() {
location.reload();
}
function deletePlaybook(playbookName) {
if (confirm(`Are you sure you want to delete the playbook "${playbookName}"?`)) {
fetch(`/ansible/playbook/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({playbook_name: playbookName})
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert('Playbook deleted successfully!');
location.reload();
} else {
alert('Error deleting playbook: ' + result.error);
}
})
.catch(error => {
alert('Error deleting playbook: ' + error);
});
}
}
</script>
{% endblock %}
</script>
@@ -0,0 +1,297 @@
{% extends "base.html" %}
{% block title %}SSH Setup - Server Monitoring{% endblock %}
{% block page_title %}SSH Setup{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="row g-4">
<!-- SSH Key Management -->
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="fas fa-key me-2"></i>SSH Key Pair</h5>
</div>
<div class="card-body">
{% if key_exists %}
<div class="alert alert-success">
<i class="fas fa-check-circle me-2"></i>SSH key exists at
<code>~/.ssh/ansible_key</code>
</div>
{% if public_key %}
<div class="mb-3">
<label class="form-label fw-semibold">Public Key</label>
<textarea class="form-control font-monospace" rows="4" readonly>{{ public_key }}</textarea>
<small class="text-muted">Copy this key to <code>~/.ssh/authorized_keys</code> on each device.</small>
</div>
{% endif %}
{% else %}
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>No SSH key found. Generate one below.
</div>
{% endif %}
<form method="post" action="{{ url_for('ansible_web.generate_ssh_keys') }}">
<button type="submit" class="btn btn-primary">
<i class="fas fa-sync me-1"></i>
{{ 'Regenerate' if key_exists else 'Generate' }} SSH Keys
</button>
</form>
</div>
</div>
</div>
<!-- SSH Settings -->
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-secondary text-white">
<h5 class="mb-0"><i class="fas fa-lock me-2"></i>SSH Authentication Settings</h5>
</div>
<div class="card-body">
<p class="text-muted small">
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 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 device password"
required>
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword()">
<i class="fas fa-eye" id="toggleIcon"></i>
</button>
</div>
<small class="text-muted">
Used when SSH key auth is not available on the target device.
</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>
</form>
</div>
</div>
</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>
function togglePassword() {
const input = document.getElementById('sshFallbackPassword');
const icon = document.getElementById('toggleIcon');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('fa-eye', 'fa-eye-slash');
} else {
input.type = 'password';
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 %}