Initial commit — Server_Monitorizare_v2
This commit is contained in:
664
templates/ansible/dashboard.html
Normal file
664
templates/ansible/dashboard.html
Normal file
@@ -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>Name</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.strftime('%Y-%m-%d %H:%M') 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.strftime('%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">Device Name</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 %}
|
||||
538
templates/ansible/devices.html
Normal file
538
templates/ansible/devices.html
Normal file
@@ -0,0 +1,538 @@
|
||||
{% 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 gap-1">
|
||||
<button class="btn btn-sm btn-success" onclick="syncDevices()">
|
||||
<i class="fas fa-sync-alt me-1"></i>Sync All
|
||||
</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. Use <strong>Sync All</strong> or add from below.</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 all DB devices into monitoring_devices ── */
|
||||
async function syncDevices() {
|
||||
const btn = event.currentTarget;
|
||||
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) {
|
||||
showAlert(`<i class="fas fa-check-circle me-1"></i> ${d.message}`, 'success');
|
||||
setTimeout(() => location.reload(), 1200);
|
||||
} else { showAlert(`Sync failed: ${d.error}`, 'danger'); }
|
||||
} catch { showAlert('Network error', 'danger'); }
|
||||
finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-sync-alt me-1"></i>Sync All';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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 %}
|
||||
612
templates/ansible/execute.html
Normal file
612
templates/ansible/execute.html
Normal file
@@ -0,0 +1,612 @@
|
||||
{% 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; }
|
||||
#liveCard { display: none; }
|
||||
#liveTerminal {
|
||||
background: #1e1e1e; color: #d4d4d4;
|
||||
font-family: 'Courier New', monospace; font-size: .78rem;
|
||||
line-height: 1.5; height: 380px; overflow-y: auto;
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
border-radius: 0 0 8px 8px; padding: 12px 16px;
|
||||
}
|
||||
#liveTerminal .ansi-ok { color: #4ec9b0; }
|
||||
#liveTerminal .ansi-changed { color: #dcdcaa; }
|
||||
#liveTerminal .ansi-fail { color: #f44747; }
|
||||
#liveTerminal .ansi-unreachable { color: #ce9178; }
|
||||
#liveTerminal .ansi-task { color: #9cdcfe; font-weight: bold; }
|
||||
#liveTerminal .ansi-play { color: #c586c0; font-weight: bold; }
|
||||
.live-header {
|
||||
background: #252526; color: #ccc; border-radius: 8px 8px 0 0;
|
||||
padding: 8px 16px; font-size: .8rem; display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.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>
|
||||
|
||||
<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 %} +{{ 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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- ── Live Execution Output ──────────────────────────────────── -->
|
||||
<div class="row mt-3" id="liveCard">
|
||||
<div class="col-12">
|
||||
<div class="card shadow">
|
||||
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
||||
<span class="fw-semibold">
|
||||
<i class="fas fa-terminal me-2"></i>Live Execution Output
|
||||
</span>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span id="liveStatusBadge" class="badge bg-secondary">Waiting…</span>
|
||||
<a id="liveDetailsLink" href="#" class="btn btn-sm btn-outline-primary" style="display:none">
|
||||
<i class="fas fa-external-link-alt me-1"></i>Full Details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-header">
|
||||
<span class="pulse-dot" id="livePulseDot"></span>
|
||||
<span id="liveStatusText">Initializing…</span>
|
||||
<span class="ms-auto text-muted" id="liveElapsed"></span>
|
||||
</div>
|
||||
<div id="liveTerminal"></div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center py-2">
|
||||
<small class="text-muted" id="liveHostSummary"></small>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="toggleAutoScroll()">
|
||||
<i class="fas fa-arrow-down" id="autoScrollIcon"></i> Auto-scroll
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" id="liveStopBtn" onclick="stopPolling()">
|
||||
<i class="fas fa-stop"></i> Stop polling
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /container-fluid -->
|
||||
|
||||
<script>
|
||||
// ── State ────────────────────────────────────────────────────────────
|
||||
let selectedPlaybook = null;
|
||||
let targetMode = 'all';
|
||||
|
||||
// Live output state
|
||||
let pollTimer = null;
|
||||
let executionId = null;
|
||||
let autoScroll = true;
|
||||
let pollStartTime = 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;
|
||||
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…';
|
||||
|
||||
resetLiveCard();
|
||||
document.getElementById('liveCard').style.display = '';
|
||||
|
||||
fetch(this.action, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: new FormData(this),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
executionId = data.execution_id;
|
||||
pollStartTime = Date.now();
|
||||
const link = document.getElementById('liveDetailsLink');
|
||||
link.href = `/ansible/executions/${executionId}`;
|
||||
link.style.display = '';
|
||||
startPolling();
|
||||
} else {
|
||||
setLiveError(data.error || 'Unknown error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-play me-1"></i>Execute';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
setLiveError('Network error: ' + err);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-play me-1"></i>Execute';
|
||||
});
|
||||
});
|
||||
|
||||
// ── Live terminal ────────────────────────────────────────────────────
|
||||
function resetLiveCard() {
|
||||
document.getElementById('liveTerminal').textContent = '';
|
||||
document.getElementById('liveStatusBadge').className = 'badge bg-secondary';
|
||||
document.getElementById('liveStatusBadge').textContent = 'Starting…';
|
||||
document.getElementById('liveStatusText').textContent = 'Initializing…';
|
||||
document.getElementById('livePulseDot').className = 'pulse-dot';
|
||||
document.getElementById('liveHostSummary').textContent = '';
|
||||
document.getElementById('liveElapsed').textContent = '';
|
||||
document.getElementById('liveDetailsLink').style.display = 'none';
|
||||
document.getElementById('liveStopBtn').style.display = '';
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
pollTimer = setInterval(pollLive, 2000);
|
||||
pollLive();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
document.getElementById('liveStopBtn').style.display = 'none';
|
||||
document.getElementById('liveStatusText').textContent += ' (polling stopped)';
|
||||
}
|
||||
|
||||
function pollLive() {
|
||||
if (!executionId) return;
|
||||
fetch(`/api/ansible/executions/${executionId}/live`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data.success) { setLiveError(data.error); return; }
|
||||
renderLiveData(data);
|
||||
if (['completed','failed','cancelled','timeout'].includes(data.status)) {
|
||||
stopPolling();
|
||||
document.getElementById('liveStopBtn').style.display = 'none';
|
||||
document.getElementById('executeButton').disabled = false;
|
||||
document.getElementById('executeButton').innerHTML = '<i class="fas fa-play me-1"></i>Execute';
|
||||
}
|
||||
})
|
||||
.catch(err => console.warn('Poll error:', err));
|
||||
}
|
||||
|
||||
function renderLiveData(data) {
|
||||
const badge = document.getElementById('liveStatusBadge');
|
||||
const dot = document.getElementById('livePulseDot');
|
||||
const colors = { running:'bg-primary', completed:'bg-success', failed:'bg-danger',
|
||||
cancelled:'bg-warning', timeout:'bg-warning' };
|
||||
badge.className = 'badge ' + (colors[data.status] || 'bg-secondary');
|
||||
badge.textContent = data.status.charAt(0).toUpperCase() + data.status.slice(1);
|
||||
dot.className = data.status === 'running' ? 'pulse-dot' :
|
||||
data.status === 'completed' ? 'pulse-dot done' : 'pulse-dot error';
|
||||
document.getElementById('liveStatusText').textContent =
|
||||
data.summary_message || `Running ${data.playbook_name} on ${(data.target_hosts||[]).join(', ')}`;
|
||||
if (pollStartTime) {
|
||||
const sec = Math.round((Date.now() - pollStartTime) / 1000);
|
||||
document.getElementById('liveElapsed').textContent = `${sec}s elapsed`;
|
||||
}
|
||||
if (data.status !== 'running') {
|
||||
document.getElementById('liveHostSummary').innerHTML =
|
||||
`✅ ${data.successful_hosts||0} ok ❌ ${data.failed_hosts||0} failed ⚠️ ${data.unreachable_hosts||0} unreachable`;
|
||||
}
|
||||
const terminal = document.getElementById('liveTerminal');
|
||||
const colorised = (data.log || '')
|
||||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/(PLAY\s+\[.*?\])/g, '<span class="ansi-play">$1</span>')
|
||||
.replace(/(TASK\s+\[.*?\])/g, '<span class="ansi-task">$1</span>')
|
||||
.replace(/(ok:.*)/g, '<span class="ansi-ok">$1</span>')
|
||||
.replace(/(changed:.*)/g, '<span class="ansi-changed">$1</span>')
|
||||
.replace(/(fatal:.*)/g, '<span class="ansi-fail">$1</span>')
|
||||
.replace(/(FAILED!.*)/g, '<span class="ansi-fail">$1</span>')
|
||||
.replace(/(UNREACHABLE!.*)/g, '<span class="ansi-unreachable">$1</span>');
|
||||
terminal.innerHTML = colorised;
|
||||
if (autoScroll) terminal.scrollTop = terminal.scrollHeight;
|
||||
}
|
||||
|
||||
function setLiveError(msg) {
|
||||
document.getElementById('liveStatusBadge').className = 'badge bg-danger';
|
||||
document.getElementById('liveStatusBadge').textContent = 'Error';
|
||||
document.getElementById('livePulseDot').className = 'pulse-dot error';
|
||||
document.getElementById('liveTerminal').textContent = 'Error: ' + msg;
|
||||
}
|
||||
|
||||
function toggleAutoScroll() {
|
||||
autoScroll = !autoScroll;
|
||||
document.getElementById('autoScrollIcon').style.opacity = autoScroll ? '1' : '0.3';
|
||||
}
|
||||
|
||||
// ── Initialize ───────────────────────────────────────────────────────
|
||||
updateTargetCount();
|
||||
updateSummary();
|
||||
{% if preselect_playbook %}
|
||||
selectPlaybook('{{ preselect_playbook }}');
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
311
templates/ansible/executions.html
Normal file
311
templates/ansible/executions.html
Normal file
@@ -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.strftime('%Y-%m-%d %H:%M') 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 %}
|
||||
685
templates/ansible/playbooks.html
Normal file
685
templates/ansible/playbooks.html
Normal file
@@ -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>
|
||||
113
templates/ansible/ssh_setup.html
Normal file
113
templates/ansible/ssh_setup.html
Normal file
@@ -0,0 +1,113 @@
|
||||
{% 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">
|
||||
When key-based authentication fails, the server falls back to password auth.
|
||||
Set the default password for devices on this network below.
|
||||
</p>
|
||||
|
||||
<form method="post" action="{{ url_for('ansible_web.save_ssh_settings') }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">SSH Fallback Password</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="ssh_fallback_password" id="sshFallbackPassword"
|
||||
class="form-control"
|
||||
value="{{ settings.get('ssh_fallback_password', '') }}"
|
||||
placeholder="Enter fallback password"
|
||||
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>
|
||||
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="fas fa-save me-1"></i>Save Settings
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /row -->
|
||||
</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');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user