Initial commit — Server_Monitorizare_v2

This commit is contained in:
ske087
2026-04-23 15:55:46 +03:00
commit d2485e4c66
61 changed files with 13861 additions and 0 deletions

241
templates/admin.html Normal file
View File

@@ -0,0 +1,241 @@
{% extends "base.html" %}
{% block title %}Admin — Server Monitoring{% endblock %}
{% block page_title %}Admin & Maintenance{% endblock %}
{% block extra_css %}
<style>
.danger-card { border: 2px solid #dc3545; }
.danger-card .card-header { background-color: #dc3545; color: #fff; }
.warning-card { border: 2px solid #fd7e14; }
.warning-card .card-header { background-color: #fd7e14; color: #fff; }
.stat-box { background: #f8f9fa; border-radius: 8px; padding: 12px 20px; text-align: center; }
.stat-box .num { font-size: 2rem; font-weight: bold; line-height: 1; }
.stat-box .lbl { font-size: .78rem; color: #6c757d; margin-top: 2px; }
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Current stats row -->
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-secondary text-white">
<h5 class="mb-0"><i class="fas fa-database me-2"></i>Current Database &amp; Inventory State</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-6 col-md-2">
<div class="stat-box">
<div class="num text-primary" id="stat-devices">{{ stats.get('devices', '?') }}</div>
<div class="lbl">Devices</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="stat-box">
<div class="num text-info" id="stat-logs">{{ stats.get('logs', '?') }}</div>
<div class="lbl">Log Entries</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="stat-box">
<div class="num text-secondary" id="stat-templates">{{ stats.get('templates', '?') }}</div>
<div class="lbl">Msg Templates</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="stat-box">
<div class="num text-danger" id="stat-wmt">{{ stats.get('wmt_requests', '?') }}</div>
<div class="lbl">WMT Requests</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="stat-box">
<div class="num text-warning" id="stat-inv-hosts">{{ stats.get('inventory_hosts', '?') }}</div>
<div class="lbl">Inventory Hosts</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="stat-box">
<div class="num text-success" id="stat-inv-groups">{{ stats.get('inventory_groups_yaml', '?') }}</div>
<div class="lbl">Inventory Groups</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<!-- Clear Log Entries -->
<div class="col-md-3">
<div class="card warning-card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-stream me-2"></i>Clear Log Entries</h5>
</div>
<div class="card-body d-flex flex-column">
<p class="text-muted flex-grow-1">
Deletes <strong>all log entries</strong> from the database.
Devices remain intact — they will start logging again automatically.
</p>
<div class="alert alert-warning py-2 mb-3">
<i class="fas fa-exclamation-triangle me-1"></i>
Currently <strong id="badge-logs">{{ stats.get('logs', '?') }}</strong> log entries.
</div>
<button class="btn btn-warning w-100"
onclick="runAction('clear-logs', 'Delete ALL log entries? This cannot be undone.')">
<i class="fas fa-trash me-2"></i>Clear All Logs
</button>
</div>
</div>
</div>
<!-- Clear Devices -->
<div class="col-md-3">
<div class="card danger-card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-server me-2"></i>Clear Device Database</h5>
</div>
<div class="card-body d-flex flex-column">
<p class="text-muted flex-grow-1">
Deletes <strong>all devices</strong> and their associated log entries from the database.
Devices will re-register automatically when they next check in.
</p>
<div class="alert alert-danger py-2 mb-3">
<i class="fas fa-exclamation-triangle me-1"></i>
Currently <strong id="badge-devices">{{ stats.get('devices', '?') }}</strong> devices
and <strong id="badge-logs2">{{ stats.get('logs', '?') }}</strong> log entries.
</div>
<button class="btn btn-danger w-100"
onclick="runAction('clear-devices', 'Delete ALL devices and their logs? This cannot be undone.')">
<i class="fas fa-trash me-2"></i>Clear All Devices
</button>
</div>
</div>
</div>
<!-- Clear Ansible Inventory -->
<div class="col-md-3">
<div class="card danger-card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-sitemap me-2"></i>Clear Ansible Inventory</h5>
</div>
<div class="card-body d-flex flex-column">
<p class="text-muted flex-grow-1">
Resets the Ansible inventory file and clears all inventory groups.
The file is left fully empty — ready for new hosts and groups.
</p>
<div class="alert alert-danger py-2 mb-3">
<i class="fas fa-exclamation-triangle me-1"></i>
Currently <strong id="badge-inv-hosts">{{ stats.get('inventory_hosts', '?') }}</strong> hosts
in <strong id="badge-inv-groups">{{ stats.get('inventory_groups_yaml', '?') }}</strong> group(s).
</div>
<button class="btn btn-danger w-100"
onclick="runAction('clear-inventory', 'Reset Ansible inventory and delete all groups? This cannot be undone.')">
<i class="fas fa-trash me-2"></i>Clear Inventory
</button>
</div>
</div>
</div>
<!-- Clear WMT Update Requests -->
<div class="col-md-3">
<div class="card warning-card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-clipboard-list me-2"></i>Clear WMT Requests</h5>
</div>
<div class="card-body d-flex flex-column">
<p class="text-muted flex-grow-1">
Deletes <strong>all WMT update requests</strong> (pending, accepted and rejected).
Devices are not affected and can submit new requests at any time.
</p>
<div class="alert alert-warning py-2 mb-3">
<i class="fas fa-exclamation-triangle me-1"></i>
Currently <strong id="badge-wmt">{{ stats.get('wmt_requests', '?') }}</strong> update request(s).
</div>
<button class="btn btn-warning w-100"
onclick="runAction('clear-wmt', 'Delete ALL WMT update requests? This cannot be undone.')">
<i class="fas fa-trash me-2"></i>Clear WMT Requests
</button>
</div>
</div>
</div>
</div><!-- /row -->
</div><!-- /container -->
<!-- Result toast -->
<div class="position-fixed bottom-0 end-0 p-3" style="z-index:9999">
<div id="resultToast" class="toast align-items-center text-white border-0" role="alert" aria-live="assertive">
<div class="d-flex">
<div class="toast-body" id="toastMsg">Done.</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>
<script>
const ENDPOINTS = {
'clear-logs': '{{ url_for("main.admin_clear_logs") }}',
'clear-devices': '{{ url_for("main.admin_clear_devices") }}',
'clear-inventory': '{{ url_for("main.admin_clear_inventory") }}',
'clear-wmt': '{{ url_for("main.admin_clear_wmt") }}'
};
function runAction(action, confirmMsg) {
if (!confirm(confirmMsg)) return;
const btn = event.currentTarget;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Working…';
fetch(ENDPOINTS[action], {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(data => {
if (data.success) {
showToast('success', buildMessage(action, data));
refreshStats();
} else {
showToast('danger', 'Error: ' + (data.error || 'Unknown'));
}
})
.catch(err => showToast('danger', 'Network error: ' + err))
.finally(() => {
btn.disabled = false;
btn.innerHTML = btn.innerHTML.replace(/<span.*?><\/span>/, '<i class="fas fa-trash me-2"></i>');
// Re-render button label properly
btn.innerHTML = '<i class="fas fa-trash me-2"></i>' + btn.textContent.trim();
});
}
function buildMessage(action, data) {
if (action === 'clear-logs')
return `Deleted ${data.deleted} log entries.`;
if (action === 'clear-devices')
return `Deleted ${data.deleted_devices} devices and ${data.deleted_logs} log entries.`;
if (action === 'clear-inventory')
return `Inventory reset. ${data.groups_deleted} group(s) removed.`;
if (action === 'clear-wmt')
return `Deleted ${data.deleted} WMT update request(s).`;
return 'Done.';
}
function showToast(type, msg) {
const toast = document.getElementById('resultToast');
toast.className = `toast align-items-center text-white bg-${type} border-0`;
document.getElementById('toastMsg').textContent = msg;
bootstrap.Toast.getOrCreateInstance(toast, {delay: 4000}).show();
}
function refreshStats() {
// Reload the page stats after a short delay to let DB settle
setTimeout(() => location.reload(), 800);
}
</script>
{% endblock %}

View 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 %}

View 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 %}

View 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 %}&nbsp;+{{ group.hosts|length - 3 }} more{% endif %}
</small>
</label>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center py-3">No groups in inventory.</p>
{% endif %}
</div>
<!-- By Host panel -->
<div id="panel-host" class="d-none" style="max-height:300px;overflow-y:auto;">
{% if all_inv_hosts %}
<div class="mb-2 d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="checkAllHosts(true)">
<i class="fas fa-check-double me-1"></i>Select All
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="checkAllHosts(false)">Clear</button>
</div>
{% for h in all_inv_hosts %}
<div class="form-check border-bottom py-2">
<input class="form-check-input host-cb" type="checkbox"
id="invhost-{{ h.hostname }}" value="{{ h.hostname }}"
onchange="updateTargetCount()">
<label class="form-check-label d-flex justify-content-between w-100" for="invhost-{{ h.hostname }}">
<strong>{{ h.hostname }}</strong>
<code class="text-muted" style="font-size:.8rem;">{{ h.ip }}</code>
</label>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center py-3">No hosts in inventory yet.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div><!-- /top row -->
<!-- ── Advanced Options ──────────────────────────────────────── -->
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-header bg-info text-white">
<h6 class="mb-0">
<i class="fas fa-cogs me-1"></i>Advanced Options
<button type="button" class="btn btn-outline-light btn-sm ms-2"
data-bs-toggle="collapse" data-bs-target="#advancedOptions">
<i class="fas fa-chevron-down"></i>
</button>
</h6>
</div>
<div class="collapse" id="advancedOptions">
<div class="card-body">
<div class="row">
<div class="col-md-4">
<h6>Execution Settings</h6>
<div class="mb-3">
<label class="form-label">Priority</label>
<select class="form-select" name="priority">
<option value="1">1 — Low</option>
<option value="3">3 — Below Normal</option>
<option value="5" selected>5 — Normal</option>
<option value="7">7 — High</option>
<option value="10">10 — Critical</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Max Retries</label>
<select class="form-select" name="max_retries">
<option value="0" selected>0 — No retries</option>
<option value="1">1 retry</option>
<option value="2">2 retries</option>
<option value="3">3 retries</option>
</select>
</div>
<div class="form-check">
<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 &nbsp; ❌ ${data.failed_hosts||0} failed &nbsp; ⚠️ ${data.unreachable_hosts||0} unreachable`;
}
const terminal = document.getElementById('liveTerminal');
const colorised = (data.log || '')
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/(PLAY\s+\[.*?\])/g, '<span class="ansi-play">$1</span>')
.replace(/(TASK\s+\[.*?\])/g, '<span class="ansi-task">$1</span>')
.replace(/(ok:.*)/g, '<span class="ansi-ok">$1</span>')
.replace(/(changed:.*)/g, '<span class="ansi-changed">$1</span>')
.replace(/(fatal:.*)/g, '<span class="ansi-fail">$1</span>')
.replace(/(FAILED!.*)/g, '<span class="ansi-fail">$1</span>')
.replace(/(UNREACHABLE!.*)/g, '<span class="ansi-unreachable">$1</span>');
terminal.innerHTML = colorised;
if (autoScroll) terminal.scrollTop = terminal.scrollHeight;
}
function setLiveError(msg) {
document.getElementById('liveStatusBadge').className = 'badge bg-danger';
document.getElementById('liveStatusBadge').textContent = 'Error';
document.getElementById('livePulseDot').className = 'pulse-dot error';
document.getElementById('liveTerminal').textContent = 'Error: ' + msg;
}
function toggleAutoScroll() {
autoScroll = !autoScroll;
document.getElementById('autoScrollIcon').style.opacity = autoScroll ? '1' : '0.3';
}
// ── Initialize ───────────────────────────────────────────────────────
updateTargetCount();
updateSummary();
{% if preselect_playbook %}
selectPlaybook('{{ preselect_playbook }}');
{% endif %}
</script>
{% endblock %}

View 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 %}

View 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>

View 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 %}

433
templates/base.html Normal file
View File

@@ -0,0 +1,433 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Server Monitoring Dashboard{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
body {
background-color: #f8f9fa;
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
/* Sidebar Styles */
.sidebar {
position: fixed;
top: 0;
left: 0;
width: 250px;
height: 100vh;
background: linear-gradient(135deg, #2c3e50, #3498db);
color: white;
padding: 20px 0;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
overflow-y: auto;
z-index: 1000;
}
.sidebar .logo {
text-align: center;
padding: 20px 15px 30px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 20px;
}
.sidebar .logo h3 {
margin: 0;
font-weight: bold;
color: #fff;
}
.sidebar .logo small {
color: #bdc3c7;
}
.sidebar .nav-menu {
list-style: none;
padding: 0 10px;
margin: 0;
}
.sidebar .nav-item {
margin-bottom: 5px;
}
.sidebar .nav-link {
display: flex;
align-items: center;
padding: 12px 15px;
color: #ecf0f1;
text-decoration: none;
border-radius: 8px;
transition: all 0.3s ease;
font-size: 14px;
}
.sidebar .nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
color: #fff;
transform: translateX(5px);
}
.sidebar .nav-link.active {
background-color: rgba(255, 255, 255, 0.2);
color: #fff;
font-weight: 500;
}
.sidebar .nav-link i {
width: 20px;
margin-right: 10px;
font-size: 16px;
}
.sidebar .nav-section {
margin: 30px 15px 10px;
font-size: 12px;
text-transform: uppercase;
color: #bdc3c7;
font-weight: 600;
letter-spacing: 0.5px;
}
.sidebar .nav-link.admin-link {
color: #ff6b6b;
margin-top: 8px;
border-top: 1px solid rgba(255,255,255,0.1);
padding-top: 14px;
}
.sidebar .nav-link.admin-link:hover {
background-color: rgba(220,53,69,0.25);
color: #ff6b6b;
}
.sidebar .nav-link.admin-link.active {
background-color: rgba(220,53,69,0.35);
color: #ff6b6b;
}
/* Main Content Styles */
.main-content {
margin-left: 250px;
padding: 20px;
min-height: 100vh;
}
.content-header {
background-color: #fff;
padding: 20px 25px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
.content-header h1 {
margin: 0;
color: #2c3e50;
font-size: 28px;
font-weight: 600;
}
.content-header .breadcrumb {
background: none;
padding: 0;
margin: 8px 0 0 0;
}
.content-header .breadcrumb-item a {
color: #3498db;
text-decoration: none;
}
.content-body {
background-color: #fff;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 25px;
}
/* Responsive Design */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.sidebar.mobile-open {
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
.mobile-menu-toggle {
display: block !important;
position: fixed;
top: 15px;
left: 15px;
z-index: 1001;
background: #3498db;
color: white;
border: none;
border-radius: 5px;
padding: 8px 12px;
cursor: pointer;
}
}
.mobile-menu-toggle {
display: none;
}
/* Additional Styles */
.table-container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
margin-bottom: 20px;
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
}
.btn-primary {
background-color: #3498db;
border-color: #3498db;
}
.btn-primary:hover {
background-color: #2980b9;
border-color: #2980b9;
}
/* Flash Messages */
.alert {
border-radius: 8px;
border: none;
margin-bottom: 20px;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- Mobile Menu Toggle -->
<button class="mobile-menu-toggle" onclick="toggleSidebar()">
<i class="fas fa-bars"></i>
</button>
<!-- Sidebar Navigation -->
<nav class="sidebar" id="sidebar">
<div class="logo">
<h3><i class="fas fa-server"></i> Monitor</h3>
<small>Server Monitoring v2.0</small>
</div>
<ul class="nav-menu">
<div class="nav-section">Device Management</div>
<li class="nav-item">
<a href="{{ url_for('main.devices') }}" class="nav-link {% if request.endpoint in ['main.devices','main.device_edit','main.device_detail'] %}active{% endif %}">
<i class="fas fa-desktop"></i>
Devices
</a>
</li>
<div class="nav-section">WMT</div>
<li class="nav-item">
<a href="{{ url_for('wmt_web.index') }}" class="nav-link {% if request.endpoint == 'wmt_web.index' %}active{% endif %}">
<i class="fas fa-sliders-h"></i>
WMT Dashboard
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('wmt_web.settings') }}" class="nav-link {% if request.endpoint == 'wmt_web.settings' %}active{% endif %}">
<i class="fas fa-cog"></i>
WMT Settings
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('wmt_web.update_requests') }}" class="nav-link {% if request.endpoint == 'wmt_web.update_requests' %}active{% endif %}">
<i class="fas fa-inbox"></i>
Update Requests
{% if pending_wmt_count > 0 %}<span class="badge bg-danger ms-1">{{ pending_wmt_count }}</span>{% endif %}
</a>
</li>
<div class="nav-section">Monitoring</div>
<li class="nav-item">
<a href="{{ url_for('main.logs') }}" class="nav-link {% if request.endpoint == 'main.logs' %}active{% endif %}">
<i class="fas fa-list-alt"></i>
Logs
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('main.templates') }}" class="nav-link {% if request.endpoint == 'main.templates' %}active{% endif %}">
<i class="fas fa-file-alt"></i>
Templates
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('main.stats') }}" class="nav-link {% if request.endpoint == 'main.stats' %}active{% endif %}">
<i class="fas fa-chart-bar"></i>
Statistics
</a>
</li>
<div class="nav-section">Automation</div>
<li class="nav-item">
<a href="{{ url_for('ansible_web.devices') }}" class="nav-link {% if request.endpoint == 'ansible_web.devices' %}active{% endif %}">
<i class="fas fa-network-wired"></i>
Remote Devices
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('ansible_web.playbooks') }}" class="nav-link {% if request.endpoint == 'ansible_web.playbooks' %}active{% endif %}">
<i class="fas fa-play"></i>
Playbooks
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('ansible_web.execute') }}" class="nav-link {% if request.endpoint == 'ansible_web.execute' %}active{% endif %}">
<i class="fas fa-terminal"></i>
Execute
</a>
</li>
<div class="nav-section">Server</div>
<li class="nav-item">
<a href="{{ url_for('main.logs') }}" class="nav-link {% if request.endpoint == 'main.logs' %}active{% endif %}">
<i class="fas fa-stream"></i>
Live Logs
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="confirmAction('Refresh Data', 'Are you sure you want to refresh all data?')">
<i class="fas fa-sync-alt"></i>
Refresh Data
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="showInfo()">
<i class="fas fa-info-circle"></i>
System Info
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('main.admin') }}" class="nav-link admin-link {% if request.endpoint == 'main.admin' %}active{% endif %}">
<i class="fas fa-shield-alt"></i>
Admin
</a>
</li>
</ul>
</nav>
<!-- Main Content Area -->
<div class="main-content">
<!-- Content Header -->
<div class="content-header">
<h1>{% block page_title %}Dashboard{% endblock %}</h1>
{% if breadcrumbs %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
{% for crumb in breadcrumbs %}
<li class="breadcrumb-item {% if loop.last %}active{% endif %}">
{% if not loop.last %}
<a href="{{ crumb.url }}">{{ crumb.title }}</a>
{% else %}
{{ crumb.title }}
{% endif %}
</li>
{% endfor %}
</ol>
</nav>
{% endif %}
</div>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="alert alert-{% if category == 'error' %}danger{% else %}{{ category }}{% endif %} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- Main Content Body -->
<div class="content-body">
{% block content %}{% endblock %}
</div>
</div>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Mobile sidebar toggle
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
sidebar.classList.toggle('mobile-open');
}
// Close sidebar when clicking outside on mobile
document.addEventListener('click', function(event) {
const sidebar = document.getElementById('sidebar');
const toggle = document.querySelector('.mobile-menu-toggle');
if (window.innerWidth <= 768 &&
!sidebar.contains(event.target) &&
!toggle.contains(event.target)) {
sidebar.classList.remove('mobile-open');
}
});
// Utility functions
function confirmAction(title, message) {
if (confirm(message)) {
// Add refresh logic here
window.location.reload();
}
}
function showInfo() {
alert('Server Monitoring System v2.0\nDeveloped for enhanced device monitoring\nFeatures: Device management, Log monitoring, Ansible automation');
}
// Auto-refresh functionality (optional)
let autoRefreshInterval;
function startAutoRefresh(seconds = 30) {
autoRefreshInterval = setInterval(() => {
window.location.reload();
}, seconds * 1000);
}
function stopAutoRefresh() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
}
}
// Initialize based on page
document.addEventListener('DOMContentLoaded', function() {
// Add any page-specific initialization here
console.log('Server Monitoring Dashboard loaded');
});
</script>
{% block extra_js %}{% endblock %}
</body>
</html>

334
templates/dashboard.html Normal file
View File

@@ -0,0 +1,334 @@
{% extends "base.html" %}
{% block title %}Dashboard - Server Monitoring{% endblock %}
{% block page_title %}Dashboard{% endblock %}
{% block extra_css %}
<style>
.stats-row {
margin-bottom: 30px;
}
.stat-card {
height: 120px;
display: flex;
align-items: center;
padding: 20px;
border-left: 4px solid #3498db;
transition: transform 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-card.devices {
border-left-color: #2ecc71;
}
.stat-card.logs {
border-left-color: #e74c3c;
}
.stat-card.templates {
border-left-color: #f39c12;
}
.stat-card.active {
border-left-color: #9b59b6;
}
.stat-icon {
font-size: 2.5rem;
margin-right: 20px;
}
.stat-details h3 {
margin: 0;
font-size: 2rem;
font-weight: bold;
}
.stat-details p {
margin: 0;
color: #7f8c8d;
font-size: 0.9rem;
}
.refresh-timer {
text-align: center;
margin-bottom: 20px;
font-size: 1.1rem;
color: #2c3e50;
font-weight: 500;
}
.action-buttons {
text-align: center;
margin-bottom: 30px;
}
.action-buttons .btn {
margin: 5px;
}
.recent-logs-section {
margin-top: 30px;
}
.activity-section {
margin-top: 30px;
}
</style>
{% endblock %}
{% block content %}
<!-- Auto-refresh Timer -->
<div class="refresh-timer">
<i class="fas fa-clock"></i>
Time until refresh: <span id="refresh-timer">30</span> seconds
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<a href="{{ url_for('main.devices') }}" class="btn btn-primary">
<i class="fas fa-desktop"></i> Manage Devices
</a>
<a href="{{ url_for('main.logs') }}" class="btn btn-secondary">
<i class="fas fa-list-alt"></i> View All Logs
</a>
<a href="{{ url_for('main.stats') }}" class="btn btn-info">
<i class="fas fa-chart-bar"></i> System Stats
</a>
<a href="{{ url_for('ansible_web.index') }}" class="btn btn-success">
<i class="fas fa-cogs"></i> Automation
</a>
<button class="btn btn-danger" onclick="resetDatabase(event)" title="Clear all logs and reset database">
<i class="fas fa-trash-alt"></i> Clear Database
</button>
</div>
<!-- Activity in Last 24 Hours -->
{% if activity_stats %}
<div class="activity-section">
<h4><i class="fas fa-clock"></i> Last 24 Hours Activity</h4>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">{{ activity_stats.logs_24h|default(0) }}</h5>
<p class="card-text text-muted">New Log Entries</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">{{ activity_stats.devices_seen_24h|default(0) }}</h5>
<p class="card-text text-muted">Devices Active</p>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Recent Logs Section -->
<div class="recent-logs-section">
<h4><i class="fas fa-list-alt"></i> Recent Activity</h4>
<div class="table-container">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th width="20%">Device</th>
<th width="15%">IP Address</th>
<th width="15%">Location</th>
<th width="20%">Timestamp</th>
<th width="30%">Event Description</th>
</tr>
</thead>
<tbody>
{% if recent_logs %}
{% for log in recent_logs %}
<tr>
<td>
<a href="{{ url_for('main.device_detail', device_id=log.device_id) }}" class="text-decoration-none">
<strong>{{ log.device.hostname }}</strong>
</a>
</td>
<td>{{ log.device.device_ip }}</td>
<td>
{% if log.device.nume_masa %}
<span class="badge bg-info">{{ log.device.nume_masa }}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<small>{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') if log.timestamp else 'N/A' }}</small>
</td>
<td>
<span class="badge bg-{% if log.severity == 'error' %}danger{% elif log.severity == 'warning' %}warning{% elif log.severity == 'info' %}primary{% else %}secondary{% endif %}">
{{ log.severity|default('info') }}
</span>
{{ log.resolved_message[:100] }}{% if log.resolved_message|length > 100 %}...{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="5" class="text-center text-muted py-4">
<i class="fas fa-inbox fa-2x"></i><br>
No recent logs available
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% if recent_logs|length >= 50 %}
<div class="text-center mt-3">
<a href="{{ url_for('main.logs') }}" class="btn btn-outline-primary">
<i class="fas fa-arrow-right"></i> View All Logs
</a>
</div>
{% endif %}
</div>
<!-- Compression Statistics -->
{% if compression_stats %}
<div class="activity-section">
<h4><i class="fas fa-compress-arrows-alt"></i> Compression Statistics</h4>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">{{ compression_stats.get('average_ratio', 0) | round(1) }}%</h5>
<p class="card-text text-muted">Compression Ratio</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">{{ compression_stats.get('total_saved_bytes', 0) | filesizeformat }}</h5>
<p class="card-text text-muted">Space Saved</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">{{ compression_stats.get('template_count', 0) }}</h5>
<p class="card-text text-muted">Active Templates</p>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
// Countdown timer for refresh
let countdown = 30; // 30 seconds
function updateTimer() {
const timerElement = document.getElementById('refresh-timer');
if (timerElement) {
timerElement.innerText = countdown;
}
countdown--;
if (countdown < 0) {
location.reload(); // Refresh the page
}
}
// Start the timer immediately
setInterval(updateTimer, 1000); // Update every second
// Database reset functionality
async function resetDatabase(event) {
try {
// First confirmation
const confirmed = confirm(
'⚠️ WARNING: Database Reset Operation ⚠️\n\n' +
'This will permanently delete ALL logs and device history!\n\n' +
'This action cannot be undone!\n\n' +
'Are you sure you want to proceed?'
);
if (!confirmed) {
return;
}
// Second confirmation for safety
const doubleConfirmed = confirm(
'🚨 FINAL CONFIRMATION 🚨\n\n' +
'You are about to permanently DELETE all data!\n\n' +
'This is your LAST CHANCE to cancel!\n\n' +
'Click OK to proceed with deletion.'
);
if (!doubleConfirmed) {
return;
}
// Show loading indicator
const button = event.target;
const originalText = button.innerHTML;
button.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span> Clearing Database...';
button.disabled = true;
// Send reset request (this would need to be implemented on the backend)
try {
const response = await fetch('/api/reset-database', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
alert('✅ Database cleared successfully!');
location.reload(); // Refresh to show empty database
} else {
throw new Error('Server error: ' + response.statusText);
}
} catch (fetchError) {
alert('❌ Error clearing database: ' + fetchError.message);
}
} catch (error) {
alert('❌ Network Error: ' + error.message);
} finally {
// Restore button
if (event.target) {
event.target.innerHTML = '<i class="fas fa-trash-alt"></i> Clear Database';
event.target.disabled = false;
}
}
}
// Initialize page
document.addEventListener('DOMContentLoaded', function() {
console.log('Dashboard loaded successfully');
// Add hover effects to stat cards
const statCards = document.querySelectorAll('.stat-card');
statCards.forEach(card => {
card.addEventListener('mouseenter', function() {
this.style.boxShadow = '0 4px 20px rgba(0, 0, 0, 0.15)';
});
card.addEventListener('mouseleave', function() {
this.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.1)';
});
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,263 @@
{% extends "base.html" %}
{% block title %}Dashboard - Server Monitoring{% endblock %}
{% block page_title %}Dashboard{% endblock %}
{% block extra_css %}
<style>
.table-container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
}
.table {
margin-bottom: 0;
table-layout: fixed; /* Ensures consistent column widths */
width: 100%; /* Makes the table take up the full container width */
}
.table th, .table td {
text-align: center;
word-wrap: break-word; /* Ensures long text wraps within the cell */
}
.table th:nth-child(1), .table td:nth-child(1) {
width: 20%; /* Hostname column */
}
.table th:nth-child(2), .table td:nth-child(2) {
width: 20%; /* Device IP column */
}
.table th:nth-child(3), .table td:nth-child(3) {
width: 20%; /* Nume Masa column */
}
.table th:nth-child(4), .table td:nth-child(4) {
width: 20%; /* Timestamp column */
}
.table th:nth-child(5), .table td:nth-child(5) {
width: 20%; /* Event Description column */
}
.refresh-timer {
text-align: center;
margin-bottom: 10px;
font-size: 1.2rem;
color: #343a40;
}
</style>
<script>
// Countdown timer for refresh
let countdown = 30; // 30 seconds
function updateTimer() {
document.getElementById('refresh-timer').innerText = countdown;
countdown--;
if (countdown < 0) {
location.reload(); // Refresh the page
}
}
setInterval(updateTimer, 1000); // Update every second
// Database reset functionality
async function resetDatabase(event) {
try {
// First, get database statistics
const statsResponse = await fetch('/database_stats');
const stats = await statsResponse.json();
if (!stats.success) {
alert('❌ Error getting database statistics:\n' + stats.error);
return;
}
const totalLogs = stats.total_logs;
const uniqueDevices = stats.unique_devices;
if (totalLogs <= 1) { // Only reset log exists
alert(' Database is already empty!\nNo user logs to delete.');
return;
}
// Show confirmation dialog with detailed statistics
const confirmed = confirm(
`⚠️ WARNING: Database Reset Operation ⚠️\n\n` +
`This will permanently delete:\n` +
`${totalLogs} log entries\n` +
`• Data from ${uniqueDevices} unique devices\n` +
`• Date range: ${stats.earliest_log || 'N/A'} to ${stats.latest_log || 'N/A'}\n\n` +
`⚠️ ALL DEVICE HISTORY WILL BE LOST ⚠️\n\n` +
`This action cannot be undone!\n\n` +
`Are you absolutely sure you want to proceed?`
);
if (!confirmed) {
return;
}
// Second confirmation for safety
const doubleConfirmed = confirm(
`🚨 FINAL CONFIRMATION 🚨\n\n` +
`You are about to permanently DELETE:\n` +
`${totalLogs} log entries\n` +
`${uniqueDevices} device histories\n\n` +
`This is your LAST CHANCE to cancel!\n\n` +
`Click OK to proceed with deletion.`
);
if (!doubleConfirmed) {
return;
}
// Show loading indicator
const button = event ? event.target : document.querySelector('button[onclick*="resetDatabase"]');
const originalText = button ? button.innerHTML : '';
if (button) {
button.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span> Clearing Database...';
button.disabled = true;
}
// Send reset request
const resetResponse = await fetch('/reset_database', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await resetResponse.json();
if (result.success) {
alert(
`✅ Database Reset Completed Successfully!\n\n` +
`Operation Summary:\n` +
`${result.deleted_count} log entries deleted\n` +
`• Database schema reinitialized\n` +
`• Reset timestamp: ${result.timestamp}\n\n` +
`The dashboard will refresh to show the clean database.`
);
location.reload(); // Refresh to show empty database
} else {
alert('❌ Database Reset Failed:\n' + result.error);
if (button) {
button.innerHTML = originalText;
button.disabled = false;
}
}
} catch (error) {
alert('❌ Network Error:\n' + error.message);
// Restore button if it was changed
try {
const button = event ? event.target : document.querySelector('button[onclick*="resetDatabase"]');
if (button) {
button.innerHTML = '<i class="fas fa-trash-alt"></i> Clear Database';
</script>
</style>
{% endblock %}
{% block extra_js %}
<script>
// Countdown timer for refresh
let countdown = 30; // 30 seconds
function updateTimer() {
const timerElement = document.getElementById('refresh-timer');
if (timerElement) {
timerElement.innerText = countdown;
}
countdown--;
if (countdown < 0) {
location.reload(); // Refresh the page
}
}
setInterval(updateTimer, 1000); // Update every second
// Database reset functionality
async function resetDatabase(event) {
try {
// First, get database statistics
const statsResponse = await fetch('/database_stats');
const stats = await statsResponse.json();
if (!stats.success) {
alert(' Error getting database statistics:\n' + stats.error);
return;
}
const totalLogs = stats.total_logs;
const uniqueDevices = stats.unique_devices;
if (totalLogs <= 1) { // Only reset log exists
alert(' Database is already empty!\nNo user logs to delete.');
return;
}
// Show confirmation dialog with detailed statistics
const confirmed = confirm(
` WARNING: Database Reset Operation \n\n` +
`This will permanently delete:\n` +
` ${totalLogs} log entries\n` +
` Data from ${uniqueDevices} unique devices\n` +
` Date range: ${stats.earliest_log || 'N/A'} to ${stats.latest_log || 'N/A'}\n\n` +
` ALL DEVICE HISTORY WILL BE LOST \n\n` +
`This action cannot be undone!\n\n` +
`Are you absolutely sure you want to proceed?`
);
if (!confirmed) {
return;
}
// Second confirmation for safety
const doubleConfirmed = confirm(
`🚨 FINAL CONFIRMATION 🚨\n\n` +
`You are about to permanently DELETE:\n` +
` ${totalLogs} log entries\n` +
` ${uniqueDevices} device histories\n\n` +
`This is your LAST CHANCE to cancel!\n\n` +
`Click OK to proceed with deletion.`
);
if (!doubleConfirmed) {
return;
}
// Show loading indicator
const button = event ? event.target : document.querySelector('button[onclick*="resetDatabase"]');
const originalText = button ? button.innerHTML : '';
if (button) {
button.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span> Clearing Database...';
button.disabled = true;
}
// Send reset request
const resetResponse = await fetch('/reset_database', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await resetResponse.json();
if (result.success) {
alert(
` Database Reset Completed Successfully!\n\n` +
`Operation Summary:\n` +
` ${result.deleted_count} log entries deleted\n` +
` Database schema reinitialized\n` +
` Reset timestamp: ${result.timestamp}\n\n` +
`The dashboard will refresh to show the clean database.`
);
location.reload(); // Refresh to show empty database
} else {
alert(' Database Reset Failed:\n' + result.error);
if (button) {
button.innerHTML = originalText;
button.disabled = false;
}
}
} catch (error) {
alert(' Network Error:\n' + error.message);
// Restore button if it was changed
try {
const button = event ? event.target : document.querySelector('button[onclick*="resetDatabase"]');
if (button) {
button.innerHTML = '<i class="fas fa-trash-alt"></i> Clear Database';

View File

@@ -0,0 +1,230 @@
{% extends "base.html" %}
{% block title %}{{ device.hostname }} {{ app_name }}{% endblock %}
{% block page_title %}Device: {{ device.hostname }}{% endblock %}
{% block extra_css %}
<style>
.severity-debug { color: #6c757d; }
.severity-info { color: #0dcaf0; }
.severity-warning { color: #ffc107; }
.severity-error { color: #dc3545; }
.severity-critical { color: #b02a37; font-weight: 600; }
.log-row td { font-size: 0.83rem; padding: 0.35rem 0.6rem; vertical-align: middle; }
.info-label { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; color: #6c757d; }
</style>
{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('main.devices') }}">Devices</a></li>
<li class="breadcrumb-item active">{{ device.hostname }}</li>
</ol>
</nav>
<!-- Header card -->
<div class="card mb-4">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<h4 class="mb-1">
{{ device.nume_masa or device.hostname }}
{% if device.status == 'active' %}
<span class="badge bg-success ms-2">Active</span>
{% elif device.status == 'maintenance' %}
<span class="badge bg-warning text-dark ms-2">Maintenance</span>
{% else %}
<span class="badge bg-danger ms-2">Offline</span>
{% endif %}
{% if device.mac_address %}
<span class="badge bg-info ms-1" title="WMT Client">WMT</span>
{% endif %}
</h4>
<div class="text-muted">{{ device.hostname }} &bull; <code>{{ device.device_ip }}</code></div>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('main.device_edit', device_id=device.id) }}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-edit me-1"></i>Edit
</a>
<a href="{{ url_for('main.logs') }}?device_id={{ device.id }}" class="btn btn-outline-info btn-sm">
<i class="fas fa-list me-1"></i>All Logs
</a>
<a href="{{ url_for('main.devices') }}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-arrow-left me-1"></i>Back
</a>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<!-- Device info -->
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header"><i class="fas fa-info-circle me-2"></i>Device Info</div>
<div class="card-body">
<table class="table table-sm table-borderless mb-0">
<tr>
<td class="info-label">Hostname</td>
<td>{{ device.hostname }}</td>
</tr>
<tr>
<td class="info-label">IP Address</td>
<td><code>{{ device.device_ip }}</code></td>
</tr>
<tr>
<td class="info-label">Device Name</td>
<td>{{ device.nume_masa or '—' }}</td>
</tr>
<tr>
<td class="info-label">Type</td>
<td>{{ device.device_type or '—' }}</td>
</tr>
<tr>
<td class="info-label">OS</td>
<td>{{ device.os_version or '—' }}</td>
</tr>
<tr>
<td class="info-label">Location</td>
<td>{{ device.location or '—' }}</td>
</tr>
{% if device.description %}
<tr>
<td class="info-label">Description</td>
<td>{{ device.description }}</td>
</tr>
{% endif %}
<tr>
<td class="info-label">Last Seen</td>
<td>{{ device.last_seen.strftime('%Y-%m-%d %H:%M:%S') if device.last_seen else '—' }}</td>
</tr>
</table>
</div>
</div>
</div>
<!-- Stats + WMT -->
<div class="col-lg-7">
<div class="row g-3 h-100">
<!-- Log stats -->
<div class="col-sm-4">
<div class="card text-center h-100">
<div class="card-body py-3">
<h3 class="mb-0">{{ log_stats.total }}</h3>
<small class="text-muted">Total Logs</small>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="card text-center h-100">
<div class="card-body py-3">
<h3 class="mb-0 text-info">{{ log_stats.last_24h }}</h3>
<small class="text-muted">Last 24 h</small>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="card text-center h-100">
<div class="card-body py-3">
<h3 class="mb-0 text-danger">{{ log_stats.by_severity.get('error', 0) + log_stats.by_severity.get('critical', 0) }}</h3>
<small class="text-muted">Errors</small>
</div>
</div>
</div>
{% if device.mac_address %}
<!-- WMT info -->
<div class="col-12">
<div class="card border-info">
<div class="card-header text-info"><i class="fas fa-sliders-h me-2"></i>WMT Client Info</div>
<div class="card-body">
<table class="table table-sm table-borderless mb-0">
<tr>
<td class="info-label">MAC Address</td>
<td><code>{{ device.mac_address }}</code></td>
</tr>
<tr>
<td class="info-label">Config Updated</td>
<td>{{ device.config_updated_at.strftime('%Y-%m-%d %H:%M:%S') if device.config_updated_at else 'Never' }}</td>
</tr>
<tr>
<td class="info-label">Info Reviewed</td>
<td>
{% if device.info_reviewed_at and device.info_reviewed_at.year > 1970 %}
<span class="text-success">{{ device.info_reviewed_at.strftime('%Y-%m-%d %H:%M:%S') }}</span>
{% else %}
<span class="text-muted">Never reviewed</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
</div>
{% endif %}
<!-- Severity breakdown -->
{% if log_stats.by_severity %}
<div class="col-12">
<div class="card">
<div class="card-header"><i class="fas fa-chart-bar me-2"></i>Severity Breakdown</div>
<div class="card-body py-2">
<div class="d-flex flex-wrap gap-2">
{% for sev, cnt in log_stats.by_severity.items() %}
<span class="badge bg-secondary">{{ sev }}: {{ cnt }}</span>
{% endfor %}
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Recent logs -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-list-alt me-2"></i>Recent Logs <small class="text-muted">(last 100)</small></span>
<a href="{{ url_for('main.logs') }}?device_id={{ device.id }}" class="btn btn-sm btn-outline-secondary">
View All
</a>
</div>
<div class="card-body p-0">
{% if logs %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr class="log-row">
<th>Timestamp</th>
<th>Severity</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr class="log-row">
<td class="text-nowrap text-muted">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') if log.timestamp else '—' }}</td>
<td>
<span class="severity-{{ log.severity }}">
<i class="fas fa-circle fa-xs me-1"></i>{{ log.severity }}
</span>
</td>
<td>{{ log.resolved_message or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted text-center py-5 mb-0">
<i class="fas fa-inbox fa-2x d-block mb-2"></i>No logs for this device yet.
</p>
{% endif %}
</div>
</div>
{% endblock %}

109
templates/device_edit.html Normal file
View File

@@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block title %}Edit {{ device.hostname }} {{ app_name }}{% endblock %}
{% block page_title %}Edit Device{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<i class="fas fa-edit me-2"></i>Edit: <strong>{{ device.hostname }}</strong>
</div>
<div class="card-body">
<form method="post">
<h6 class="text-muted mb-3">Monitoring Fields</h6>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Hostname <span class="text-danger">*</span></label>
<input type="text" name="hostname" class="form-control" required
value="{{ device.hostname }}">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">IP Address <span class="text-danger">*</span></label>
<input type="text" name="device_ip" class="form-control" required
value="{{ device.device_ip }}">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Device Name / Masa <span class="text-danger">*</span></label>
<input type="text" name="nume_masa" class="form-control" required
value="{{ device.nume_masa or '' }}">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Status</label>
<select name="status" class="form-select">
<option value="active" {% if device.status == 'active' %}selected{% endif %}>Active</option>
<option value="inactive" {% if device.status == 'inactive' %}selected{% endif %}>Inactive</option>
<option value="maintenance" {% if device.status == 'maintenance' %}selected{% endif %}>Maintenance</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Device Type</label>
<input type="text" name="device_type" class="form-control"
value="{{ device.device_type or '' }}" placeholder="Raspberry Pi, PC, Server…">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Physical Location</label>
<input type="text" name="location" class="form-control"
value="{{ device.location or '' }}" placeholder="Floor 2, Room 201">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">OS Version</label>
<input type="text" name="os_version" class="form-control"
value="{{ device.os_version or '' }}" placeholder="Raspberry Pi OS 11">
</div>
<div class="col-12">
<label class="form-label fw-semibold">Description</label>
<textarea name="description" class="form-control" rows="2">{{ device.description or '' }}</textarea>
</div>
</div>
<hr>
<h6 class="text-muted mb-3">WMT Client Fields</h6>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label fw-semibold">MAC Address
<small class="text-muted fw-normal">(WMT client identifier)</small>
</label>
<input type="text" name="mac_address" class="form-control"
value="{{ device.mac_address or '' }}"
placeholder="b8:27:eb:aa:bb:cc"
pattern="^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$">
<div class="form-text">Leave empty if this is not a WMT client device.</div>
</div>
</div>
{% if device.mac_address %}
<div class="alert alert-light border small mb-4">
<strong>Config last updated:</strong>
{{ device.config_updated_at.strftime('%Y-%m-%d %H:%M:%S') if device.config_updated_at else '—' }}<br>
<strong>Info reviewed at:</strong>
<span class="{% if device.info_reviewed_at and device.info_reviewed_at.year > 1970 %}text-success{% else %}text-muted{% endif %}">
{{ device.info_reviewed_at.strftime('%Y-%m-%d %H:%M:%S') if (device.info_reviewed_at and device.info_reviewed_at.year > 1970) else 'Never reviewed' }}
</span><br>
<strong>Last seen:</strong>
{{ device.last_seen.strftime('%Y-%m-%d %H:%M:%S') if device.last_seen else 'Never' }}
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i>Save Changes
</button>
<a href="{{ url_for('main.devices') }}" class="btn btn-outline-secondary">Cancel</a>
<form method="post" action="{{ url_for('main.device_delete', device_id=device.id) }}"
class="ms-auto"
onsubmit="return confirm('Delete {{ device.hostname }}? All logs will also be deleted.')">
<button type="submit" class="btn btn-outline-danger">
<i class="fas fa-trash me-1"></i>Delete Device
</button>
</form>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logs for Device: {{ nume_masa }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<style>
body {
background-color: #f8f9fa;
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
color: #343a40;
}
.table-container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
}
.table {
margin-bottom: 0;
table-layout: fixed; /* Ensures consistent column widths */
width: 100%; /* Makes the table take up the full container width */
}
.table th, .table td {
text-align: center;
word-wrap: break-word; /* Ensures long text wraps within the cell */
}
.table th:nth-child(1), .table td:nth-child(1) {
width: 20%; /* Device ID column */
}
.table th:nth-child(2), .table td:nth-child(2) {
width: 20%; /* Nume Masa column */
}
.table th:nth-child(3), .table td:nth-child(3) {
width: 30%; /* Timestamp column */
}
.table th:nth-child(4), .table td:nth-child(4) {
width: 30%; /* Event Description column */
}
.back-button {
margin-bottom: 20px;
text-align: center;
}
</style>
</head>
<body>
<div class="container mt-5">
<h1 class="mb-4">Logs for Device: {{ nume_masa }}</h1>
<div class="back-button">
<a href="/dashboard" class="btn btn-primary">Back to Dashboard</a>
</div>
<div class="table-container">
<table class="table table-striped table-bordered">
<thead class="table-dark">
<tr>
<th>Device ID</th>
<th>Nume Masa</th>
<th>Timestamp</th>
<th>Event Description</th>
</tr>
</thead>
<tbody>
{% if logs %}
{% for log in logs %}
<tr>
<td>{{ log[0] }}</td>
<td>{{ log[1] }}</td>
<td>{{ log[2] }}</td>
<td>{{ log[3] }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4">No logs found for this device.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<footer>
<p class="text-center mt-4">&copy; 2023 Device Logs Dashboard. All rights reserved.</p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,278 @@
{% extends "base.html" %}
{% block title %}Devices {{ app_name }}{% endblock %}
{% block page_title %}Devices{% endblock %}
{% block extra_css %}
<style>
.mac-badge { font-size: 0.75rem; letter-spacing: 0.03em; }
.sync-ok { color: #2ecc71; }
.sync-old { color: #e74c3c; }
.tbl-sm td, .tbl-sm th { padding: 0.45rem 0.6rem; font-size: 0.88rem; vertical-align: middle; }
</style>
{% endblock %}
{% block content %}
<!-- Stats row -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-2">
<div class="card text-center h-100">
<div class="card-body py-3">
<h4 class="mb-0 text-success">{{ devices|selectattr('status','equalto','active')|list|length }}</h4>
<small class="text-muted">Active</small>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card text-center h-100">
<div class="card-body py-3">
<h4 class="mb-0 text-danger">{{ devices|selectattr('status','equalto','inactive')|list|length }}</h4>
<small class="text-muted">Offline</small>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card text-center h-100">
<div class="card-body py-3">
<h4 class="mb-0 text-warning">{{ devices|selectattr('status','equalto','maintenance')|list|length }}</h4>
<small class="text-muted">Maintenance</small>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card text-center h-100">
<div class="card-body py-3">
<h4 class="mb-0 text-primary">{{ devices|length }}</h4>
<small class="text-muted">Total</small>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card text-center h-100">
<div class="card-body py-3">
<h4 class="mb-0 text-info">{{ devices|selectattr('mac_address')|list|length }}</h4>
<small class="text-muted">WMT Clients</small>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<a href="{{ url_for('wmt_web.update_requests', status='pending') }}" class="card text-center h-100 text-decoration-none">
<div class="card-body py-3">
<h4 class="mb-0 {% if pending_count > 0 %}text-danger{% else %}text-secondary{% endif %}">{{ pending_count }}</h4>
<small class="text-muted">Pending Requests</small>
</div>
</a>
</div>
</div>
<!-- Toolbar -->
<div class="d-flex flex-wrap gap-2 mb-3 align-items-center">
<input type="text" id="deviceSearch" class="form-control" style="max-width:340px"
placeholder="Search hostname, IP, name, MAC…" oninput="filterTable()">
<button class="btn btn-primary ms-auto" data-bs-toggle="modal" data-bs-target="#addDeviceModal">
<i class="fas fa-plus me-1"></i>Add Device
</button>
<button class="btn btn-outline-secondary" onclick="location.reload()">
<i class="fas fa-sync-alt me-1"></i>Refresh
</button>
</div>
<!-- Device table -->
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover tbl-sm mb-0" id="deviceTable">
<thead class="table-light">
<tr>
<th>Device Name</th>
<th>Hostname</th>
<th>IP</th>
<th>MAC Address</th>
<th>Status</th>
<th>Logs</th>
<th>Last Seen</th>
<th>Config Sync</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr class="device-row"
data-search="{{ device.hostname|lower }} {{ device.device_ip }} {{ (device.nume_masa or '')|lower }} {{ (device.mac_address or '')|lower }}">
<td>
<strong>{{ device.nume_masa or '—' }}</strong>
</td>
<td>{{ device.hostname }}</td>
<td><code>{{ device.device_ip }}</code></td>
<td>
{% if device.mac_address %}
<code class="mac-badge">{{ device.mac_address }}</code>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
{% if device.status == 'active' %}
<span class="badge bg-success">Active</span>
{% elif device.status == 'maintenance' %}
<span class="badge bg-warning text-dark">Maintenance</span>
{% else %}
<span class="badge bg-danger">Offline</span>
{% endif %}
</td>
<td>{{ device_log_counts.get(device.id, 0) }}</td>
<td class="text-muted">
{% if device.last_seen %}
{{ device.last_seen.strftime('%Y-%m-%d %H:%M') }}
{% else %}—{% endif %}
</td>
<td>
{% if device.mac_address and device.config_updated_at %}
<span class="sync-ok" title="{{ device.config_updated_at.strftime('%Y-%m-%d %H:%M:%S') }}">
<i class="fas fa-check-circle"></i>
{{ device.config_updated_at.strftime('%m-%d %H:%M') }}
</span>
{% elif device.mac_address %}
<span class="sync-old"><i class="fas fa-clock"></i> Never</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-end text-nowrap">
<a href="{{ url_for('main.device_detail', device_id=device.id) }}"
class="btn btn-sm btn-outline-primary py-0" title="View Details">
<i class="fas fa-eye"></i>
</a>
<a href="{{ url_for('main.logs', device_id=device.id) }}"
class="btn btn-sm btn-outline-info py-0" title="View Logs">
<i class="fas fa-list"></i>
</a>
<a href="{{ url_for('main.device_edit', device_id=device.id) }}"
class="btn btn-sm btn-outline-secondary py-0" title="Edit">
<i class="fas fa-edit"></i>
</a>
<form method="post" action="{{ url_for('main.device_delete', device_id=device.id) }}"
class="d-inline"
onsubmit="return confirm('Delete device {{ device.hostname }}? This also removes all its logs.')">
<button type="submit" class="btn btn-sm btn-outline-danger py-0" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="9" class="text-center text-muted py-5">
<i class="fas fa-desktop fa-2x mb-2 d-block"></i>
No devices registered yet.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Add Device Modal -->
<div class="modal fade" id="addDeviceModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-plus-circle me-2"></i>Add Device</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="addDeviceForm" onsubmit="submitAddDevice(event)">
<div class="modal-body">
<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" name="hostname" required placeholder="RPI-Masa-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" name="device_ip" required placeholder="192.168.1.100">
</div>
<div class="col-md-6">
<label class="form-label">Device Name / Masa <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="nume_masa" required placeholder="Masa-01">
</div>
<div class="col-md-6">
<label class="form-label">MAC Address
<small class="text-muted">(WMT clients only)</small>
</label>
<input type="text" class="form-control" name="mac_address"
placeholder="b8:27:eb:aa:bb:cc"
pattern="^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$">
</div>
<div class="col-md-6">
<label class="form-label">Device Type</label>
<select class="form-select" name="device_type">
<option value="Raspberry Pi">Raspberry Pi</option>
<option value="PC">PC/Workstation</option>
<option value="Server">Server</option>
<option value="unknown" selected>Unknown</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Status</label>
<select class="form-select" name="status">
<option value="active" selected>Active</option>
<option value="inactive">Inactive</option>
<option value="maintenance">Maintenance</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Physical Location</label>
<input type="text" class="form-control" name="location" placeholder="Floor 2, Room 201">
</div>
<div class="col-md-6">
<label class="form-label">OS Version</label>
<input type="text" class="form-control" name="os_version" placeholder="Raspberry Pi OS 11">
</div>
<div class="col-12">
<label class="form-label">Description</label>
<textarea class="form-control" name="description" rows="2"></textarea>
</div>
</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"><i class="fas fa-save me-1"></i>Add Device</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function filterTable() {
const q = document.getElementById('deviceSearch').value.toLowerCase();
document.querySelectorAll('.device-row').forEach(row => {
row.style.display = row.dataset.search.includes(q) ? '' : 'none';
});
}
async function submitAddDevice(event) {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.target).entries());
const btn = event.target.querySelector('[type=submit]');
btn.disabled = true;
try {
const resp = await fetch('/api/devices/add', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
const result = await resp.json();
if (resp.ok) { location.reload(); }
else { alert('Error: ' + (result.message || 'Unknown error')); }
} catch(e) { alert('Error: ' + e.message); }
finally { btn.disabled = false; }
}
</script>
{% endblock %}

View File

@@ -0,0 +1,507 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Device Management</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
body {
background-color: #f8f9fa;
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
color: #343a40;
}
.card {
margin-bottom: 20px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.device-card {
border-left: 4px solid #007bff;
}
.status-online {
color: #28a745;
}
.status-offline {
color: #dc3545;
}
.command-buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 15px;
}
.back-button {
margin-bottom: 20px;
text-align: center;
}
.search-container {
margin-bottom: 20px;
}
.search-input {
max-width: 400px;
margin: 0 auto;
}
.loading {
display: none;
}
.result-container {
margin-top: 15px;
padding: 10px;
border-radius: 5px;
display: none;
}
.result-success {
background-color: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.result-error {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
</style>
</head>
<body>
<div class="container mt-5">
<h1 class="mb-4">Device Management</h1>
<div class="back-button">
<a href="/dashboard" class="btn btn-primary">Back to Dashboard</a>
<a href="/unique_devices" class="btn btn-secondary">View Unique Devices</a>
<a href="/server_logs" class="btn btn-info" title="View server operations and system logs">
<i class="fas fa-server"></i> Server Logs
</a>
</div>
<!-- Search Filter -->
<div class="search-container">
<div class="search-input">
<input type="text" id="searchInput" class="form-control" placeholder="Search devices by hostname or IP...">
</div>
</div>
<!-- Bulk Operations -->
<div class="card">
<div class="card-header">
<h5>Bulk Operations</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<label for="bulkCommand" class="form-label">Select Command:</label>
<select class="form-select" id="bulkCommand">
<option value="">Select a command...</option>
<option value="sudo apt update">Update Package Lists</option>
<option value="sudo apt upgrade -y">Upgrade Packages</option>
<option value="sudo apt update && sudo apt upgrade -y">Update and Upgrade Device</option>
<option value="sudo apt autoremove -y">Remove Unused Packages</option>
<option value="df -h">Check Disk Space</option>
<option value="free -m">Check Memory Usage</option>
<option value="uptime">Check Uptime</option>
<option value="sudo systemctl restart networking">Restart Networking</option>
<option value="sudo reboot">Reboot Device</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">&nbsp;</label>
<div>
<button class="btn btn-warning" onclick="executeOnAllDevices()">Execute on All Devices</button>
<button class="btn btn-info" onclick="executeOnSelectedDevices()">Execute on Selected</button>
<button class="btn btn-danger" onclick="autoUpdateAllDevices()" title="Auto-update all devices to latest app.py version">
Auto Update All
</button>
<button class="btn btn-dark" onclick="autoUpdateSelectedDevices()" title="Auto-update selected devices">
Auto Update Selected
</button>
</div>
</div>
</div>
<div class="result-container" id="bulkResult"></div>
</div>
</div>
<!-- Device List -->
<div id="deviceContainer">
{% for device in devices %}
<div class="card device-card" data-hostname="{{ device[0] }}" data-ip="{{ device[1] }}">
<div class="card-header">
<div class="row align-items-center">
<div class="col-md-6">
<h6 class="mb-0">
<input type="checkbox" class="device-checkbox me-2" value="{{ device[1] }}">
<strong>{{ device[0] }}</strong> ({{ device[1] }})
</h6>
</div>
<div class="col-md-3">
<small class="text-muted">Last seen: {{ device[2] }}</small>
</div>
<div class="col-md-3 text-end">
<span class="badge bg-secondary status" id="status-{{ device[1] }}">Checking...</span>
<button class="btn btn-sm btn-outline-info" onclick="checkDeviceStatus('{{ device[1] }}')">
Refresh Status
</button>
</div>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-8">
<select class="form-select command-select" id="command-{{ device[1] }}">
<option value="">Select a command...</option>
<option value="sudo apt update">Update Package Lists</option>
<option value="sudo apt upgrade -y">Upgrade Packages</option>
<option value="sudo apt update && sudo apt upgrade -y">Update and Upgrade Device</option>
<option value="sudo apt autoremove -y">Remove Unused Packages</option>
<option value="df -h">Check Disk Space</option>
<option value="free -m">Check Memory Usage</option>
<option value="uptime">Check Uptime</option>
<option value="sudo systemctl restart networking">Restart Networking</option>
<option value="sudo reboot">Reboot Device</option>
</select>
</div>
<div class="col-md-4">
<button class="btn btn-success" onclick="executeCommand('{{ device[1] }}')">
Execute Command
</button>
<button class="btn btn-warning" onclick="autoUpdateDevice('{{ device[1] }}')" title="Auto-update app.py to latest version">
Auto Update
</button>
</div>
</div>
<div class="result-container" id="result-{{ device[1] }}"></div>
<div class="loading" id="loading-{{ device[1] }}">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
Executing command...
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Search functionality
document.getElementById('searchInput').addEventListener('keyup', function() {
const filter = this.value.toLowerCase();
const devices = document.querySelectorAll('.device-card');
devices.forEach(device => {
const hostname = device.dataset.hostname.toLowerCase();
const ip = device.dataset.ip.toLowerCase();
if (hostname.includes(filter) || ip.includes(filter)) {
device.style.display = '';
} else {
device.style.display = 'none';
}
});
});
// Check device status
async function checkDeviceStatus(deviceIp) {
const statusElement = document.getElementById(`status-${deviceIp}`);
statusElement.textContent = 'Checking...';
statusElement.className = 'badge bg-secondary';
try {
const response = await fetch(`/device_status/${deviceIp}`);
const result = await response.json();
if (result.success) {
statusElement.textContent = 'Online';
statusElement.className = 'badge bg-success';
} else {
statusElement.textContent = 'Offline';
statusElement.className = 'badge bg-danger';
}
} catch (error) {
statusElement.textContent = 'Error';
statusElement.className = 'badge bg-danger';
}
}
// Execute command on single device
async function executeCommand(deviceIp) {
const commandSelect = document.getElementById(`command-${deviceIp}`);
const command = commandSelect.value;
if (!command) {
alert('Please select a command first');
return;
}
const loadingElement = document.getElementById(`loading-${deviceIp}`);
const resultElement = document.getElementById(`result-${deviceIp}`);
loadingElement.style.display = 'block';
resultElement.style.display = 'none';
try {
const response = await fetch('/execute_command', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
device_ip: deviceIp,
command: command
})
});
const result = await response.json();
loadingElement.style.display = 'none';
resultElement.style.display = 'block';
if (result.success) {
resultElement.className = 'result-container result-success';
resultElement.innerHTML = `
<strong>Success:</strong> ${result.result.message}<br>
<small><strong>Output:</strong><br><pre>${result.result.output}</pre></small>
`;
} else {
resultElement.className = 'result-container result-error';
resultElement.innerHTML = `<strong>Error:</strong> ${result.error}`;
}
} catch (error) {
loadingElement.style.display = 'none';
resultElement.style.display = 'block';
resultElement.className = 'result-container result-error';
resultElement.innerHTML = `<strong>Network Error:</strong> ${error.message}`;
}
}
// Execute command on all devices
async function executeOnAllDevices() {
const command = document.getElementById('bulkCommand').value;
if (!command) {
alert('Please select a command first');
return;
}
const deviceIps = Array.from(document.querySelectorAll('.device-card')).map(card => card.dataset.ip);
await executeBulkCommand(deviceIps, command);
}
// Execute command on selected devices
async function executeOnSelectedDevices() {
const command = document.getElementById('bulkCommand').value;
if (!command) {
alert('Please select a command first');
return;
}
const selectedIps = Array.from(document.querySelectorAll('.device-checkbox:checked')).map(cb => cb.value);
if (selectedIps.length === 0) {
alert('Please select at least one device');
return;
}
await executeBulkCommand(selectedIps, command);
}
// Execute bulk command
async function executeBulkCommand(deviceIps, command) {
const resultElement = document.getElementById('bulkResult');
resultElement.style.display = 'block';
resultElement.className = 'result-container';
resultElement.innerHTML = '<div class="spinner-border spinner-border-sm" role="status"></div> Executing commands...';
try {
const response = await fetch('/execute_command_bulk', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
device_ips: deviceIps,
command: command
})
});
const result = await response.json();
let html = '<h6>Bulk Execution Results:</h6>';
let successCount = 0;
for (const [ip, deviceResult] of Object.entries(result.results)) {
if (deviceResult.success) {
successCount++;
html += `<div class="alert alert-success alert-sm">✓ ${ip}: ${deviceResult.result.message}</div>`;
} else {
html += `<div class="alert alert-danger alert-sm">✗ ${ip}: ${deviceResult.error}</div>`;
}
}
html += `<div class="mt-2"><strong>Summary:</strong> ${successCount}/${deviceIps.length} devices succeeded</div>`;
resultElement.className = 'result-container result-success';
resultElement.innerHTML = html;
} catch (error) {
resultElement.className = 'result-container result-error';
resultElement.innerHTML = `<strong>Network Error:</strong> ${error.message}`;
}
}
// Auto-update functionality
async function autoUpdateDevice(deviceIp) {
const resultElement = document.getElementById(`result-${deviceIp}`);
const loadingElement = document.getElementById(`loading-${deviceIp}`);
try {
// Show loading
loadingElement.style.display = 'block';
resultElement.className = 'result-container';
resultElement.innerHTML = '';
const response = await fetch('/auto_update_devices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
device_ips: [deviceIp]
})
});
const result = await response.json();
loadingElement.style.display = 'none';
if (result.results && result.results.length > 0) {
const deviceResult = result.results[0];
if (deviceResult.success) {
if (deviceResult.status === 'no_update_needed') {
resultElement.className = 'result-container result-success';
resultElement.innerHTML = `<strong>No Update Needed:</strong> Device is already running version ${deviceResult.new_version || 'latest'}`;
} else {
resultElement.className = 'result-container result-success';
resultElement.innerHTML = `<strong>Update Success:</strong> ${deviceResult.message}<br>
<small>Updated from v${deviceResult.old_version} to v${deviceResult.new_version}</small><br>
<small class="text-warning">Device is restarting...</small>`;
}
} else {
resultElement.className = 'result-container result-error';
resultElement.innerHTML = `<strong>Update Failed:</strong> ${deviceResult.error}`;
}
} else {
resultElement.className = 'result-container result-error';
resultElement.innerHTML = '<strong>Error:</strong> No response from server';
}
} catch (error) {
loadingElement.style.display = 'none';
resultElement.className = 'result-container result-error';
resultElement.innerHTML = `<strong>Network Error:</strong> ${error.message}`;
}
}
async function autoUpdateAllDevices() {
if (!confirm('Are you sure you want to auto-update ALL devices? This will restart all devices.')) {
return;
}
await performBulkAutoUpdate('all');
}
async function autoUpdateSelectedDevices() {
const selectedDevices = Array.from(document.querySelectorAll('.device-checkbox:checked'))
.map(cb => cb.value);
if (selectedDevices.length === 0) {
alert('Please select at least one device');
return;
}
if (!confirm(`Are you sure you want to auto-update ${selectedDevices.length} selected device(s)? This will restart the selected devices.`)) {
return;
}
await performBulkAutoUpdate('selected');
}
async function performBulkAutoUpdate(mode) {
const resultElement = document.getElementById('bulkResult');
try {
// Determine which devices to update
let deviceIps;
if (mode === 'all') {
deviceIps = Array.from(document.querySelectorAll('.device-card'))
.map(card => card.dataset.ip);
} else {
deviceIps = Array.from(document.querySelectorAll('.device-checkbox:checked'))
.map(cb => cb.value);
}
// Show loading state
resultElement.className = 'result-container';
resultElement.innerHTML = `<div class="alert alert-info">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
Auto-updating ${deviceIps.length} device(s)... This may take several minutes.
</div>`;
const response = await fetch('/auto_update_devices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
device_ips: deviceIps
})
});
const result = await response.json();
let html = '<h6>Auto-Update Results:</h6>';
let successCount = 0;
for (const deviceResult of result.results) {
if (deviceResult.success) {
successCount++;
if (deviceResult.status === 'no_update_needed') {
html += `<div class="alert alert-info alert-sm"> ${deviceResult.device_ip}: Already up to date</div>`;
} else {
html += `<div class="alert alert-success alert-sm">✓ ${deviceResult.device_ip}: ${deviceResult.message}</div>`;
}
} else {
html += `<div class="alert alert-danger alert-sm">✗ ${deviceResult.device_ip}: ${deviceResult.error}</div>`;
}
}
html += `<div class="mt-2"><strong>Summary:</strong> ${successCount}/${deviceIps.length} devices updated successfully</div>`;
if (successCount > 0) {
html += `<div class="alert alert-warning mt-2"><small>Note: Updated devices are restarting and may be temporarily unavailable.</small></div>`;
}
resultElement.className = 'result-container result-success';
resultElement.innerHTML = html;
} catch (error) {
resultElement.className = 'result-container result-error';
resultElement.innerHTML = `<strong>Network Error:</strong> ${error.message}`;
}
}
// Check status of all devices on page load
document.addEventListener('DOMContentLoaded', function() {
const devices = document.querySelectorAll('.device-card');
devices.forEach(device => {
const ip = device.dataset.ip;
checkDeviceStatus(ip);
});
});
</script>
</body>
</html>

0
templates/devices.html Normal file
View File

23
templates/errors/400.html Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<title>400 - Bad Request</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background-color: #f5f5f5; }
.error-container { max-width: 500px; margin: 0 auto; background: white; padding: 40px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #f39c12; font-size: 72px; margin: 0; }
h2 { color: #333; margin: 20px 0; }
p { color: #666; line-height: 1.6; }
.back-link { display: inline-block; margin-top: 20px; padding: 10px 20px; background: #3498db; color: white; text-decoration: none; border-radius: 5px; }
.back-link:hover { background: #2980b9; }
</style>
</head>
<body>
<div class="error-container">
<h1>400</h1>
<h2>Bad Request</h2>
<p>The server could not understand your request.</p>
<a href="/" class="back-link">← Back to Dashboard</a>
</div>
</body>
</html>

23
templates/errors/403.html Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<title>403 - Forbidden</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background-color: #f5f5f5; }
.error-container { max-width: 500px; margin: 0 auto; background: white; padding: 40px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #e67e22; font-size: 72px; margin: 0; }
h2 { color: #333; margin: 20px 0; }
p { color: #666; line-height: 1.6; }
.back-link { display: inline-block; margin-top: 20px; padding: 10px 20px; background: #3498db; color: white; text-decoration: none; border-radius: 5px; }
.back-link:hover { background: #2980b9; }
</style>
</head>
<body>
<div class="error-container">
<h1>403</h1>
<h2>Forbidden</h2>
<p>You don't have permission to access this resource.</p>
<a href="/" class="back-link">← Back to Dashboard</a>
</div>
</body>
</html>

56
templates/errors/404.html Normal file
View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html>
<head>
<title>404 - Page Not Found</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
padding: 50px;
background-color: #f5f5f5;
}
.error-container {
max-width: 500px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #e74c3c;
font-size: 72px;
margin: 0;
}
h2 {
color: #333;
margin: 20px 0;
}
p {
color: #666;
line-height: 1.6;
}
.back-link {
display: inline-block;
margin-top: 20px;
padding: 10px 20px;
background: #3498db;
color: white;
text-decoration: none;
border-radius: 5px;
}
.back-link:hover {
background: #2980b9;
}
</style>
</head>
<body>
<div class="error-container">
<h1>404</h1>
<h2>Page Not Found</h2>
<p>The page you are looking for doesn't exist or has been moved.</p>
<p>Try checking the URL or use the navigation menu.</p>
<a href="/" class="back-link">← Back to Dashboard</a>
</div>
</body>
</html>

23
templates/errors/500.html Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<title>500 - Internal Server Error</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background-color: #f5f5f5; }
.error-container { max-width: 500px; margin: 0 auto; background: white; padding: 40px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #e74c3c; font-size: 72px; margin: 0; }
h2 { color: #333; margin: 20px 0; }
p { color: #666; line-height: 1.6; }
.back-link { display: inline-block; margin-top: 20px; padding: 10px 20px; background: #3498db; color: white; text-decoration: none; border-radius: 5px; }
.back-link:hover { background: #2980b9; }
</style>
</head>
<body>
<div class="error-container">
<h1>500</h1>
<h2>Internal Server Error</h2>
<p>Something went wrong on our end. Please try again later.</p>
<a href="/" class="back-link">← Back to Dashboard</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logs for Hostname: {{ hostname }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<style>
body {
background-color: #f8f9fa;
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
color: #343a40;
}
.table-container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
}
.table {
margin-bottom: 0;
table-layout: fixed; /* Ensures consistent column widths */
width: 100%; /* Makes the table take up the full container width */
}
.table th, .table td {
text-align: center;
word-wrap: break-word; /* Ensures long text wraps within the cell */
}
.table th:nth-child(1), .table td:nth-child(1) {
width: 25%; /* Device ID column */
}
.table th:nth-child(2), .table td:nth-child(2) {
width: 25%; /* Nume Masa column */
}
.table th:nth-child(3), .table td:nth-child(3) {
width: 25%; /* Timestamp column */
}
.table th:nth-child(4), .table td:nth-child(4) {
width: 25%; /* Event Description column */
}
.back-button {
margin-bottom: 20px;
text-align: center;
}
</style>
</head>
<body>
<div class="container mt-5">
<h1 class="mb-4">Logs for Hostname: {{ hostname }}</h1>
<div class="back-button">
<a href="/dashboard" class="btn btn-primary">Back to Dashboard</a>
</div>
<div class="table-container">
<table class="table table-striped table-bordered">
<thead class="table-dark">
<tr>
<th>Device ID</th>
<th>Nume Masa</th>
<th>Timestamp</th>
<th>Event Description</th>
</tr>
</thead>
<tbody>
{% if logs %}
{% for log in logs %}
<tr>
<td>{{ log[0] }}</td>
<td>{{ log[1] }}</td>
<td>{{ log[2] }}</td>
<td>{{ log[3] }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4">No logs found for this hostname.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<footer>
<p class="text-center mt-4">&copy; 2023 Device Logs Dashboard. All rights reserved.</p>
</footer>
</body>
</html>

336
templates/logs.html Normal file
View File

@@ -0,0 +1,336 @@
{% extends "base.html" %}
{% block title %}Logs - Server Monitoring{% endblock %}
{% block page_title %}Log Viewer{% endblock %}
{% block extra_css %}
<style>
.filter-container {
background-color: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.log-entry {
border-left: 4px solid #dee2e6;
margin-bottom: 10px;
transition: all 0.2s ease;
}
.log-entry.severity-error {
border-left-color: #dc3545;
}
.log-entry.severity-warning {
border-left-color: #ffc107;
}
.log-entry.severity-info {
border-left-color: #17a2b8;
}
.log-entry.severity-debug {
border-left-color: #6c757d;
}
.log-entry:hover {
background-color: #f8f9fa;
cursor: pointer;
}
.log-meta {
font-size: 0.9rem;
color: #6c757d;
}
.severity-badge {
font-size: 0.8rem;
padding: 0.3rem 0.6rem;
border-radius: 50px;
}
.pagination-container {
margin-top: 30px;
}
.log-message {
font-family: 'Courier New', monospace;
font-size: 0.9rem;
margin: 10px 0;
word-break: break-word;
}
</style>
{% endblock %}
{% block content %}
<!-- Filter Section -->
<div class="filter-container">
<form method="GET" class="row g-3">
<div class="col-md-3">
<label for="device_id" class="form-label">Device</label>
<select class="form-select" name="device_id" id="device_id">
<option value="">All Devices</option>
{% for device in devices %}
<option value="{{ device.id }}" {% if current_device_id == device.id %}selected{% endif %}>
{{ device.hostname }} ({{ device.device_ip }})
</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label for="severity" class="form-label">Severity</label>
<select class="form-select" name="severity" id="severity">
<option value="">All Levels</option>
<option value="error" {% if current_severity == 'error' %}selected{% endif %}>Error</option>
<option value="warning" {% if current_severity == 'warning' %}selected{% endif %}>Warning</option>
<option value="info" {% if current_severity == 'info' %}selected{% endif %}>Info</option>
<option value="debug" {% if current_severity == 'debug' %}selected{% endif %}>Debug</option>
</select>
</div>
<div class="col-md-4">
<label for="search" class="form-label">Search Message</label>
<input type="text" class="form-control" name="search" id="search"
placeholder="Search in log messages..." value="{{ current_search }}">
</div>
<div class="col-md-2">
<label for="per_page" class="form-label">Per Page</label>
<select class="form-select" name="per_page" id="per_page">
<option value="25" {% if pagination.per_page == 25 %}selected{% endif %}>25</option>
<option value="50" {% if pagination.per_page == 50 %}selected{% endif %}>50</option>
<option value="100" {% if pagination.per_page == 100 %}selected{% endif %}>100</option>
</select>
</div>
<div class="col-md-1">
<label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-search"></i>
</button>
</div>
</form>
</div>
<!-- Results Summary -->
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<strong>Showing {{ logs|length }} of {{ pagination.total|default(0) }} log entries</strong>
{% if current_device_id or current_severity or current_search %}
<a href="{{ url_for('main.logs') }}" class="btn btn-sm btn-outline-secondary ms-2">
<i class="fas fa-times"></i> Clear Filters
</a>
{% endif %}
</div>
<div>
<button class="btn btn-success btn-sm" onclick="refreshLogs()">
<i class="fas fa-sync-alt"></i> Refresh
</button>
<button class="btn btn-info btn-sm" onclick="exportLogs()">
<i class="fas fa-download"></i> Export
</button>
</div>
</div>
<!-- Log Entries -->
<div class="log-entries">
{% if logs %}
{% for log in logs %}
<div class="card log-entry severity-{{ log.severity|default('info') }}"
onclick="toggleLogDetail('{{ loop.index }}')">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="d-flex align-items-center mb-2">
<span class="severity-badge bg-{% if log.severity == 'error' %}danger{% elif log.severity == 'warning' %}warning{% elif log.severity == 'info' %}primary{% else %}secondary{% endif %} text-white">
{{ log.severity|default('info')|upper }}
</span>
<strong class="ms-2">{{ log.device.hostname }}</strong>
<span class="text-muted ms-2">({{ log.device.device_ip }})</span>
{% if log.device.nume_masa %}
<span class="badge bg-info ms-2">{{ log.device.nume_masa }}</span>
{% endif %}
</div>
<div class="log-message">
{{ log.resolved_message|default(log.full_message)|truncate(200) }}
</div>
<div class="log-meta mt-2">
<i class="fas fa-clock"></i> {{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') if log.timestamp else 'N/A' }}
{% if log.template_hash %}
<span class="ms-3"><i class="fas fa-tag"></i> Template: {{ log.template_hash[:8] }}</span>
{% endif %}
</div>
</div>
<div class="text-end">
<button class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation(); viewDevice('{{ log.device_id }}')">
<i class="fas fa-desktop"></i>
</button>
</div>
</div>
<!-- Detailed log view (initially hidden) -->
<div id="detail-{{ loop.index }}" class="log-detail mt-3" style="display: none;">
<hr>
<h6>Full Message:</h6>
<pre class="bg-light p-3 rounded">{{ log.full_message|default('No detailed message available') }}</pre>
{% if log.resolved_message != log.full_message %}
<h6>Resolved Message:</h6>
<div class="bg-info bg-opacity-10 p-3 rounded">
{{ log.resolved_message }}
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-5">
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
<h4 class="text-muted">No Logs Found</h4>
<p class="text-muted">No log entries match the current filters.</p>
{% if current_device_id or current_severity or current_search %}
<a href="{{ url_for('main.logs') }}" class="btn btn-primary">
<i class="fas fa-arrow-left"></i> View All Logs
</a>
{% endif %}
</div>
{% endif %}
</div>
<!-- Pagination -->
{% if pagination and pagination.total_pages > 1 %}
<div class="pagination-container">
<nav aria-label="Log pagination">
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.logs', page=pagination.prev_num, device_id=current_device_id, severity=current_severity, search=current_search, per_page=pagination.per_page) }}">
<i class="fas fa-chevron-left"></i> Previous
</a>
</li>
{% endif %}
{% for page_num in range(1, pagination.total_pages + 1) %}
{% if page_num == pagination.page %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% elif page_num <= pagination.page + 2 and page_num >= pagination.page - 2 %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.logs', page=page_num, device_id=current_device_id, severity=current_severity, search=current_search, per_page=pagination.per_page) }}">
{{ page_num }}
</a>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.logs', page=pagination.next_num, device_id=current_device_id, severity=current_severity, search=current_search, per_page=pagination.per_page) }}">
Next <i class="fas fa-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
<div class="text-center text-muted">
Page {{ pagination.page }} of {{ pagination.total_pages }}
({{ pagination.total }} total entries)
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
function toggleLogDetail(index) {
const detail = document.getElementById(`detail-${index}`);
if (detail.style.display === 'none') {
detail.style.display = 'block';
} else {
detail.style.display = 'none';
}
}
function viewDevice(deviceId) {
window.location.href = `{{ url_for('main.device_detail', device_id=0) }}`.replace('0', deviceId);
}
function refreshLogs() {
window.location.reload();
}
function exportLogs() {
// Create export parameters from current filters
const params = new URLSearchParams(window.location.search);
params.set('export', 'csv');
// Create download link
const downloadUrl = `{{ url_for('main.logs') }}?${params.toString()}`;
const link = document.createElement('a');
link.href = downloadUrl;
link.download = 'logs-export.csv';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// Auto-refresh option
let autoRefreshInterval;
function startAutoRefresh(seconds = 30) {
autoRefreshInterval = setInterval(() => {
refreshLogs();
}, seconds * 1000);
}
function stopAutoRefresh() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
}
}
// Initialize page
document.addEventListener('DOMContentLoaded', function() {
console.log('Logs page loaded');
// Add keyboard shortcuts
document.addEventListener('keydown', function(event) {
if (event.ctrlKey || event.metaKey) {
if (event.key === 'f') {
event.preventDefault();
document.getElementById('search').focus();
}
if (event.key === 'r') {
event.preventDefault();
refreshLogs();
}
}
});
// Auto-submit form when filters change
const filterForm = document.querySelector('.filter-container form');
const autoSubmitElements = ['device_id', 'severity', 'per_page'];
autoSubmitElements.forEach(id => {
const element = document.getElementById(id);
if (element) {
element.addEventListener('change', () => filterForm.submit());
}
});
});
</script>
{% endblock %}

230
templates/server_logs.html Normal file
View File

@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Server Logs - System Operations</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
body {
background-color: #f8f9fa;
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
color: #343a40;
}
.table-container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
}
.table {
margin-bottom: 0;
table-layout: fixed;
width: 100%;
}
.table th, .table td {
text-align: center;
word-wrap: break-word;
}
.table th:nth-child(1), .table td:nth-child(1) {
width: 15%; /* Hostname column */
}
.table th:nth-child(2), .table td:nth-child(2) {
width: 15%; /* Device IP column */
}
.table th:nth-child(3), .table td:nth-child(3) {
width: 15%; /* Operation Type column */
}
.table th:nth-child(4), .table td:nth-child(4) {
width: 20%; /* Timestamp column */
}
.table th:nth-child(5), .table td:nth-child(5) {
width: 35%; /* Event Description column */
}
.refresh-timer {
text-align: center;
margin-bottom: 10px;
font-size: 1.2rem;
color: #343a40;
}
.server-log-header {
background: linear-gradient(135deg, #dc3545, #6c757d);
color: white;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.server-badge {
background-color: #dc3545;
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8em;
}
.operation-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8em;
font-weight: bold;
}
.operation-system { background-color: #6c757d; color: white; }
.operation-auto_update { background-color: #ffc107; color: black; }
.operation-command { background-color: #0d6efd; color: white; }
.operation-reset { background-color: #dc3545; color: white; }
.stats-row {
background-color: #f8f9fa;
border: 2px solid #dee2e6;
}
</style>
<script>
// Countdown timer for refresh
let countdown = 30;
function updateTimer() {
document.getElementById('refresh-timer').innerText = countdown;
countdown--;
if (countdown < 0) {
location.reload();
}
}
setInterval(updateTimer, 1000);
// Function to get operation type from description
function getOperationType(description) {
if (description.toLowerCase().includes('auto-update') || description.toLowerCase().includes('auto_update')) {
return 'auto_update';
} else if (description.toLowerCase().includes('command')) {
return 'command';
} else if (description.toLowerCase().includes('reset') || description.toLowerCase().includes('clear')) {
return 'reset';
} else {
return 'system';
}
}
// Apply operation badges after page load
document.addEventListener('DOMContentLoaded', function() {
const rows = document.querySelectorAll('tbody tr:not(.stats-row)');
rows.forEach(row => {
const descCell = row.cells[4];
const operationCell = row.cells[2];
const description = descCell.textContent;
const operationType = getOperationType(description);
operationCell.innerHTML = `<span class="operation-badge operation-${operationType}">${operationType.toUpperCase().replace('_', '-')}</span>`;
});
});
</script>
</head>
<body>
<div class="container mt-5">
<div class="server-log-header text-center">
<h1 class="mb-2">
<i class="fas fa-server"></i> Server Operations Log
</h1>
<p class="mb-0">System operations, auto-updates, database resets, and server commands</p>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="refresh-timer">
<i class="fas fa-clock"></i> Auto-refresh: <span id="refresh-timer">30</span> seconds
</div>
<div>
<a href="/dashboard" class="btn btn-primary">
<i class="fas fa-tachometer-alt"></i> Dashboard
</a>
<a href="/device_management" class="btn btn-success">
<i class="fas fa-cogs"></i> Device Management
</a>
<a href="/unique_devices" class="btn btn-secondary">
<i class="fas fa-list"></i> Unique Devices
</a>
</div>
</div>
<div class="table-container">
{% if logs %}
<!-- Statistics Row -->
<div class="mb-3 p-3 stats-row rounded">
<div class="row text-center">
<div class="col-md-3">
<h5 class="text-primary">{{ logs|length }}</h5>
<small>Total Operations</small>
</div>
<div class="col-md-3">
<h5 class="text-success">
{% set system_ops = logs|selectattr('2', 'equalto', 'SYSTEM')|list|length %}
{{ system_ops }}
</h5>
<small>System Operations</small>
</div>
<div class="col-md-3">
<h5 class="text-warning">
{% set auto_updates = 0 %}
{% for log in logs %}
{% if 'auto' in log[4]|lower or 'update' in log[4]|lower %}
{% set auto_updates = auto_updates + 1 %}
{% endif %}
{% endfor %}
{{ auto_updates }}
</h5>
<small>Auto-Updates</small>
</div>
<div class="col-md-3">
<h5 class="text-danger">
{% set resets = 0 %}
{% for log in logs %}
{% if 'reset' in log[4]|lower or 'clear' in log[4]|lower %}
{% set resets = resets + 1 %}
{% endif %}
{% endfor %}
{{ resets }}
</h5>
<small>Database Resets</small>
</div>
</div>
</div>
<table class="table table-striped table-bordered">
<thead class="table-dark">
<tr>
<th><i class="fas fa-server"></i> Source</th>
<th><i class="fas fa-network-wired"></i> IP Address</th>
<th><i class="fas fa-cog"></i> Operation</th>
<th><i class="fas fa-clock"></i> Timestamp</th>
<th><i class="fas fa-info-circle"></i> Description</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td><span class="server-badge">{{ log[0] }}</span></td>
<td>{{ log[1] }}</td>
<td>{{ log[2] }}</td>
<td>{{ log[3] }}</td>
<td class="text-start">{{ log[4] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center py-5">
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
<h4 class="text-muted">No Server Operations Found</h4>
<p class="text-muted">No server operations have been logged yet.</p>
<a href="/dashboard" class="btn btn-primary">
<i class="fas fa-arrow-left"></i> Back to Dashboard
</a>
</div>
{% endif %}
</div>
</div>
<footer>
<p class="text-center mt-4">&copy; 2025 Server Operations Dashboard. All rights reserved.</p>
</footer>
</body>
</html>

211
templates/stats.html Normal file
View File

@@ -0,0 +1,211 @@
{% extends "base.html" %}
{% block title %}System Statistics - Server Monitoring{% endblock %}
{% block page_title %}System Statistics{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Device Statistics -->
<div class="row mb-4">
<div class="col-12">
<h4>Device Statistics</h4>
</div>
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<h5 class="card-title">Total Devices</h5>
<h2>{{ device_stats.get('total', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<h5 class="card-title">Active</h5>
<h2>{{ device_stats.get('active', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-white">
<div class="card-body">
<h5 class="card-title">Inactive</h5>
<h2>{{ device_stats.get('inactive', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body">
<h5 class="card-title">Maintenance</h5>
<h2>{{ device_stats.get('maintenance', 0) }}</h2>
</div>
</div>
</div>
</div>
<!-- Log Statistics -->
<div class="row mb-4">
<div class="col-12">
<h4>Log Activity</h4>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Last Hour</h5>
<h2 class="text-primary">{{ log_stats.get('last_hour', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Last 24 Hours</h5>
<h2 class="text-success">{{ log_stats.get('last_24h', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Last Week</h5>
<h2 class="text-info">{{ log_stats.get('last_week', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Last Month</h5>
<h2 class="text-warning">{{ log_stats.get('last_month', 0) }}</h2>
</div>
</div>
</div>
</div>
<!-- Compression Statistics -->
<div class="row mb-4">
<div class="col-12">
<h4>Message Compression</h4>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Messages Compressed</h5>
<h2 class="text-primary">{{ compression_stats.get('total_compressed', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Space Saved</h5>
<h2 class="text-success">{{ compression_stats.get('total_saved_bytes', 0) | filesizeformat }}</h2>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Compression Ratio</h5>
<h2 class="text-info">{{ compression_stats.get('average_ratio', 0) | round(1) }}%</h2>
</div>
</div>
</div>
</div>
<!-- Ansible Execution Statistics -->
<div class="row mb-4">
<div class="col-12">
<h4>Ansible Executions</h4>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Total Executions</h5>
<h2 class="text-primary">{{ exec_stats.get('total', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<h5 class="card-title">Successful</h5>
<h2>{{ exec_stats.get('successful', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-danger text-white">
<div class="card-body">
<h5 class="card-title">Failed</h5>
<h2>{{ exec_stats.get('failed', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-white">
<div class="card-body">
<h5 class="card-title">Running</h5>
<h2>{{ exec_stats.get('running', 0) }}</h2>
</div>
</div>
</div>
</div>
<!-- System Health Chart -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>System Health Overview</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>Device Status Distribution</h6>
<div class="progress mb-3">
{% set total_devices = device_stats.get('total', 0) %}
{% if total_devices > 0 %}
<div class="progress-bar bg-success" style="width: {{ (device_stats.get('active', 0) * 100 / total_devices) }}%">
Active ({{ device_stats.get('active', 0) }})
</div>
<div class="progress-bar bg-warning" style="width: {{ (device_stats.get('inactive', 0) * 100 / total_devices) }}%">
Inactive ({{ device_stats.get('inactive', 0) }})
</div>
<div class="progress-bar bg-info" style="width: {{ (device_stats.get('maintenance', 0) * 100 / total_devices) }}%">
Maintenance ({{ device_stats.get('maintenance', 0) }})
</div>
{% else %}
<div class="progress-bar bg-secondary" style="width: 100%">
No devices configured
</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<h6>Ansible Success Rate</h6>
<div class="progress mb-3">
{% set total_exec = exec_stats.get('total', 0) %}
{% if total_exec > 0 %}
<div class="progress-bar bg-success" style="width: {{ (exec_stats.get('successful', 0) * 100 / total_exec) }}%">
Success ({{ exec_stats.get('successful', 0) }})
</div>
<div class="progress-bar bg-danger" style="width: {{ (exec_stats.get('failed', 0) * 100 / total_exec) }}%">
Failed ({{ exec_stats.get('failed', 0) }})
</div>
{% else %}
<div class="progress-bar bg-secondary" style="width: 100%">
No executions yet
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

100
templates/templates.html Normal file
View File

@@ -0,0 +1,100 @@
{% extends "base.html" %}
{% block title %}Message Templates - Server Monitoring{% endblock %}
{% block page_title %}Message Templates{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Template 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 Templates</h5>
<h2>{{ template_stats.get('total', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<h5 class="card-title">Total Usage</h5>
<h2>{{ template_stats.get('total_usage', 0) }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body">
<h5 class="card-title">Categories</h5>
<h2>{{ template_stats.get('by_category', {}) | 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">Avg Usage</h5>
<h2>{{ (template_stats.get('total_usage', 0) / template_stats.get('total', 1)) | round(1) }}</h2>
</div>
</div>
</div>
</div>
<!-- Templates Table -->
<div class="card">
<div class="card-header">
<h5>Message Templates</h5>
<small class="text-muted">Manage and view compressed message templates</small>
</div>
<div class="card-body">
{% if templates %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Hash</th>
<th>Template Text</th>
<th>Category</th>
<th>Usage Count</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for template in templates %}
<tr>
<td><code>{{ template.template_hash[:8] }}...</code></td>
<td>{{ template.template_text[:80] }}{% if template.template_text | length > 80 %}...{% endif %}</td>
<td><span class="badge bg-secondary">{{ template.category or 'uncategorized' }}</span></td>
<td>{{ template.usage_count }}</td>
<td>{{ template.created_at.strftime('%Y-%m-%d %H:%M') if template.created_at else 'N/A' }}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="viewTemplate('{{ template.template_hash }}')">
<i class="fas fa-eye"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-file-alt fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No templates found</h5>
<p class="text-muted">Message templates will appear here when devices start sending compressed logs.</p>
</div>
{% endif %}
</div>
</div>
</div>
<script>
function viewTemplate(hash) {
// Implementation for viewing template details
alert('Template details for: ' + hash);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unique Devices</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<style>
body {
background-color: #f8f9fa;
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
color: #343a40;
}
.table-container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
}
.table {
margin-bottom: 0;
table-layout: fixed; /* Ensures consistent column widths */
width: 100%; /* Makes the table take up the full container width */
}
.table th, .table td {
text-align: center;
word-wrap: break-word; /* Ensures long text wraps within the cell */
}
.table th:nth-child(1), .table td:nth-child(1) {
width: 25%; /* Hostname column */
}
.table th:nth-child(2), .table td:nth-child(2) {
width: 25%; /* Device IP column */
}
.table th:nth-child(3), .table td:nth-child(3) {
width: 25%; /* Last Log column */
}
.table th:nth-child(4), .table td:nth-child(4) {
width: 25%; /* Event Description column */
}
.back-button {
margin-bottom: 20px;
text-align: center;
}
.search-container {
margin-bottom: 20px;
}
.search-input {
max-width: 400px;
margin: 0 auto;
}
.no-results {
display: none;
text-align: center;
color: #6c757d;
font-style: italic;
padding: 20px;
}
</style>
</head>
<body>
<div class="container mt-5">
<h1 class="mb-4">Unique Devices</h1>
<div class="back-button">
<a href="/dashboard" class="btn btn-primary">Back to Dashboard</a>
</div>
<!-- Search Filter -->
<div class="search-container">
<div class="search-input">
<input type="text" id="searchInput" class="form-control" placeholder="Search devices by hostname, IP, or event description...">
</div>
</div>
<div class="table-container">
<table class="table table-striped table-bordered" id="devicesTable">
<thead class="table-dark">
<tr>
<th>Hostname</th>
<th>Device IP</th>
<th>Last Log</th>
<th>Event Description</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr>
<!-- Make the Hostname column clickable -->
<td>
<a href="/hostname_logs/{{ device[0] }}">{{ device[0] }}</a>
</td>
<td>{{ device[1] }}</td> <!-- Device IP -->
<td>{{ device[2] }}</td> <!-- Last Log -->
<td>{{ device[3] }}</td> <!-- Event Description -->
</tr>
{% endfor %}
</tbody>
</table>
<div id="noResults" class="no-results">
No devices found matching your search criteria.
</div>
</div>
</div>
<!-- JavaScript for filtering -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('searchInput');
const table = document.getElementById('devicesTable');
const tableBody = table.getElementsByTagName('tbody')[0];
const noResultsDiv = document.getElementById('noResults');
searchInput.addEventListener('keyup', function() {
const filter = this.value.toLowerCase();
const rows = tableBody.getElementsByTagName('tr');
let visibleRowCount = 0;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const cells = row.getElementsByTagName('td');
let found = false;
// Search through all cells in the row
for (let j = 0; j < cells.length; j++) {
const cellText = cells[j].textContent || cells[j].innerText;
if (cellText.toLowerCase().indexOf(filter) > -1) {
found = true;
break;
}
}
if (found) {
row.style.display = '';
visibleRowCount++;
} else {
row.style.display = 'none';
}
}
// Show/hide "no results" message
if (visibleRowCount === 0 && filter !== '') {
noResultsDiv.style.display = 'block';
} else {
noResultsDiv.style.display = 'none';
}
});
});
</script>
<footer>
<p class="text-center mt-4">&copy; 2023 Unique Devices Dashboard. All rights reserved.</p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}{% if device %}Edit Device{% else %}New Device{% endif %} WMT {{ app_name }}{% endblock %}
{% block page_title %}{% if device %}Edit Device{% else %}New Device{% endif %}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card">
<div class="card-header">
<i class="fas fa-desktop me-2"></i>
{% if device %}
Edit: <code>{{ device.mac_address }}</code>
{% else %}
Register New WMT Device
{% endif %}
</div>
<div class="card-body">
<form method="post">
<div class="mb-3">
<label class="form-label fw-semibold">MAC Address <span class="text-danger">*</span>
<small class="text-muted fw-normal">(unique identifier, e.g. b8:27:eb:aa:bb:cc)</small>
</label>
{% if device %}
<input type="text" class="form-control" value="{{ device.mac_address }}" readonly disabled>
<small class="text-muted">MAC cannot be changed after registration.</small>
{% else %}
<input type="text" name="mac_address" class="form-control"
placeholder="b8:27:eb:aa:bb:cc" required
pattern="^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$">
{% endif %}
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Work Place
<small class="text-muted fw-normal">(table / workstation identifier)</small>
</label>
<input type="text" name="device_name" class="form-control"
value="{{ device.device_name or '' if device else '' }}"
placeholder="Masa-01">
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Hostname</label>
<input type="text" name="hostname" class="form-control"
value="{{ device.hostname or '' if device else '' }}"
placeholder="rpi-masa01">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">IP Address</label>
<input type="text" name="device_ip" class="form-control"
value="{{ device.device_ip or '' if device else '' }}"
placeholder="192.168.1.100">
</div>
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Notes</label>
<textarea name="notes" class="form-control" rows="2">{{ device.notes or '' if device else '' }}</textarea>
</div>
{% if device %}
<div class="alert alert-light border small mb-4">
<strong>Last seen:</strong>
{{ device.last_seen.strftime('%Y-%m-%d %H:%M:%S') if device.last_seen else 'Never' }}<br>
<strong>Config updated:</strong>
{{ device.config_updated_at.strftime('%Y-%m-%d %H:%M:%S') if device.config_updated_at else '—' }}<br>
<strong>Info reviewed at:</strong>
<span class="{% if device.info_reviewed_at and device.info_reviewed_at.year > 1970 %}text-success{% else %}text-muted{% endif %}">
{{ device.info_reviewed_at.strftime('%Y-%m-%d %H:%M:%S') if (device.info_reviewed_at and device.info_reviewed_at.year > 1970) else 'Never reviewed' }}
</span>
<br><small class="text-muted">Updated automatically when you save this form, accept or reject a device request.</small>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i> {% if device %}Save Changes{% else %}Register Device{% endif %}
</button>
<a href="{{ url_for('wmt_web.devices') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block title %}WMT Devices {{ app_name }}{% endblock %}
{% block page_title %}WMT Devices{% endblock %}
{% block content %}
<div class="mb-3 d-flex justify-content-between align-items-center">
<p class="text-muted mb-0">{{ devices | length }} device(s) registered.</p>
<a href="{{ url_for('wmt_web.device_new') }}" class="btn btn-success">
<i class="fas fa-plus me-1"></i> New Device
</a>
</div>
<div class="card">
<div class="card-body p-0">
{% if devices %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Work Place</th>
<th>MAC Address</th>
<th>Hostname</th>
<th>IP Address</th>
<th>Last Seen</th>
<th>Config Updated</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for d in devices %}
<tr>
<td><strong>{{ d.device_name or '—' }}</strong></td>
<td><code>{{ d.mac_address }}</code></td>
<td>{{ d.hostname or '—' }}</td>
<td>{{ d.device_ip or '—' }}</td>
<td class="text-muted small">
{{ d.last_seen.strftime('%Y-%m-%d %H:%M') if d.last_seen else 'Never' }}
</td>
<td class="text-muted small">
{{ d.config_updated_at.strftime('%Y-%m-%d %H:%M') if d.config_updated_at else '—' }}
</td>
<td class="text-end">
<a href="{{ url_for('wmt_web.device_edit', device_id=d.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="fas fa-edit"></i> Edit
</a>
<form method="post" action="{{ url_for('wmt_web.device_delete', device_id=d.id) }}"
class="d-inline"
onsubmit="return confirm('Delete device {{ d.mac_address }}?')">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-desktop fa-3x mb-3 opacity-25"></i>
<p>No devices registered yet. <a href="{{ url_for('wmt_web.device_new') }}">Add the first one</a>.</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}

181
templates/wmt/index.html Normal file
View File

@@ -0,0 +1,181 @@
{% extends "base.html" %}
{% block title %}WMT Management {{ app_name }}{% endblock %}
{% block extra_css %}
<style>
.stat-card { border-left: 4px solid; }
.stat-card.blue { border-color: #3498db; }
.stat-card.green { border-color: #2ecc71; }
.stat-card.orange{ border-color: #f39c12; }
.stat-card.red { border-color: #e74c3c; }
.badge-pending { background-color: #f39c12; }
.badge-accepted { background-color: #2ecc71; }
.badge-rejected { background-color: #e74c3c; }
</style>
{% endblock %}
{% block page_title %}WMT Management{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col">
<a href="{{ url_for('wmt_web.settings') }}" class="btn btn-primary me-2">
<i class="fas fa-cog"></i> Global Settings
</a>
<a href="{{ url_for('main.devices') }}" class="btn btn-outline-primary me-2">
<i class="fas fa-desktop"></i> Devices
</a>
<a href="{{ url_for('wmt_web.update_requests') }}" class="btn btn-outline-warning">
<i class="fas fa-inbox"></i> Update Requests
{% if pending_count > 0 %}
<span class="badge bg-danger ms-1">{{ pending_count }}</span>
{% endif %}
</a>
</div>
</div>
<!-- Stats row -->
<div class="row g-3 mb-4">
<div class="col-sm-6 col-lg-3">
<div class="card stat-card blue h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Registered Devices</p>
<h4 class="mb-0">{{ devices | length }}</h4>
</div>
<i class="fas fa-desktop fa-2x text-primary opacity-50"></i>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card stat-card orange h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Pending Requests</p>
<h4 class="mb-0">{{ pending_count }}</h4>
</div>
<i class="fas fa-clock fa-2x text-warning opacity-50"></i>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card stat-card green h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Config Last Updated</p>
<h6 class="mb-0">
{% if global_cfg and global_cfg.updated_at %}
{{ global_cfg.updated_at.strftime('%Y-%m-%d %H:%M') }}
{% else %}
Never
{% endif %}
</h6>
</div>
<i class="fas fa-sync fa-2x text-success opacity-50"></i>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card stat-card red h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Chrome URL</p>
<small class="text-truncate d-block" style="max-width:160px">
{{ global_cfg.chrome_url if global_cfg else '—' }}
</small>
</div>
<i class="fas fa-globe fa-2x text-danger opacity-50"></i>
</div>
</div>
</div>
</div>
</div>
<div class="row g-3">
<!-- Device list -->
<div class="col-lg-7">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="fas fa-desktop me-2"></i>WMT Client Devices</strong>
<a href="{{ url_for('main.devices') }}" class="btn btn-sm btn-success">
<i class="fas fa-plus"></i> Add
</a>
</div>
<div class="card-body p-0">
{% if devices %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Device Name</th>
<th>MAC</th>
<th>IP</th>
<th>Last Seen</th>
<th></th>
</tr>
</thead>
<tbody>
{% for d in devices %}
<tr>
<td><strong>{{ d.device_name or '—' }}</strong></td>
<td><code>{{ d.mac_address }}</code></td>
<td>{{ d.device_ip or '—' }}</td>
<td class="text-muted small">
{{ d.last_seen.strftime('%Y-%m-%d %H:%M') if d.last_seen else 'Never' }}
</td>
<td>
<a href="{{ url_for('main.device_edit', device_id=d.id) }}"
class="btn btn-xs btn-outline-primary btn-sm py-0">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted p-3 mb-0">No WMT client devices registered yet.
<a href="{{ url_for('main.devices') }}">Manage devices</a>.
</p>
{% endif %}
</div>
</div>
</div>
<!-- Recent update requests -->
<div class="col-lg-5">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="fas fa-inbox me-2"></i>Recent Requests</strong>
<a href="{{ url_for('wmt_web.update_requests') }}" class="btn btn-sm btn-outline-secondary">
View All
</a>
</div>
<div class="card-body p-0">
{% if recent_requests %}
<ul class="list-group list-group-flush">
{% for r in recent_requests %}
<li class="list-group-item d-flex justify-content-between align-items-start py-2">
<div>
<code class="small">{{ r.mac_address }}</code><br>
<small class="text-muted">{{ r.submitted_at.strftime('%Y-%m-%d %H:%M') }}</small>
</div>
<span class="badge badge-{{ r.status }} rounded-pill">{{ r.status }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted p-3 mb-0">No recent requests.</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

124
templates/wmt/requests.html Normal file
View File

@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}Update Requests WMT {{ app_name }}{% endblock %}
{% block extra_css %}
<style>
.badge-pending { background-color: #f39c12; color: #fff; }
.badge-accepted { background-color: #2ecc71; color: #fff; }
.badge-rejected { background-color: #e74c3c; color: #fff; }
</style>
{% endblock %}
{% block page_title %}
WMT Update Requests
{% if pending_count > 0 %}
<span class="badge bg-danger ms-2">{{ pending_count }} pending</span>
{% endif %}
{% endblock %}
{% block content %}
<!-- Filter tabs -->
<ul class="nav nav-tabs mb-4">
<li class="nav-item">
<a class="nav-link {% if status_filter == 'pending' %}active{% endif %}"
href="{{ url_for('wmt_web.update_requests', status='pending') }}">
Pending
{% if pending_count > 0 %}<span class="badge bg-danger ms-1">{{ pending_count }}</span>{% endif %}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'accepted' %}active{% endif %}"
href="{{ url_for('wmt_web.update_requests', status='accepted') }}">Accepted</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'rejected' %}active{% endif %}"
href="{{ url_for('wmt_web.update_requests', status='rejected') }}">Rejected</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'all' %}active{% endif %}"
href="{{ url_for('wmt_web.update_requests', status='all') }}">All</a>
</li>
</ul>
{% if requests %}
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>MAC Address</th>
<th>Proposed Device Name</th>
<th>Proposed Hostname</th>
<th>Proposed IP</th>
<th>Submitted</th>
<th>Client Config Time</th>
<th>Status</th>
{% if status_filter == 'pending' or status_filter == 'all' %}
<th class="text-end">Actions</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for r in requests %}
<tr>
<td class="text-muted small">{{ r.id }}</td>
<td><code>{{ r.mac_address }}</code></td>
<td>{{ r.proposed_device_name or '—' }}</td>
<td>{{ r.proposed_hostname or '—' }}</td>
<td>{{ r.proposed_device_ip or '—' }}</td>
<td class="text-muted small">{{ r.submitted_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td class="text-muted small">{{ r.client_config_mtime or '—' }}</td>
<td>
<span class="badge badge-{{ r.status }} rounded-pill">{{ r.status }}</span>
{% if r.admin_reviewed_at %}
<br><small class="text-muted">{{ r.admin_reviewed_at.strftime('%Y-%m-%d %H:%M') }}</small>
{% endif %}
</td>
{% if status_filter == 'pending' or status_filter == 'all' %}
<td class="text-end">
{% if r.status == 'pending' %}
<!-- Accept -->
<form method="post" action="{{ url_for('wmt_web.accept_request', req_id=r.id) }}"
class="d-inline"
onsubmit="return confirm('Accept this request and update the device record?')">
<button type="submit" class="btn btn-sm btn-success">
<i class="fas fa-check"></i> Accept
</button>
</form>
<!-- Reject -->
<form method="post" action="{{ url_for('wmt_web.reject_request', req_id=r.id) }}"
class="d-inline ms-1"
onsubmit="return confirm('Reject this request?')">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="fas fa-times"></i> Reject
</button>
</form>
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
{% endif %}
</tr>
{% if r.admin_notes %}
<tr class="table-light">
<td colspan="9" class="small text-muted ps-4">
<i class="fas fa-comment me-1"></i> Admin note: {{ r.admin_notes }}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-inbox fa-3x mb-3 opacity-25"></i>
<p>No {{ status_filter }} requests found.</p>
</div>
{% endif %}
{% endblock %}

109
templates/wmt/settings.html Normal file
View File

@@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block title %}Global Settings WMT {{ app_name }}{% endblock %}
{% block page_title %}WMT Global Settings{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-9">
<div class="card">
<div class="card-header">
<i class="fas fa-cog me-2"></i>
Global Configuration
<small class="text-muted ms-3">
Applied to all WMT devices on next sync.
{% if cfg and cfg.updated_at %}
Last saved: {{ cfg.updated_at.strftime('%Y-%m-%d %H:%M:%S') }} by {{ cfg.updated_by or 'admin' }}
{% endif %}
</small>
</div>
<div class="card-body">
<form method="post">
<h6 class="text-uppercase text-muted mb-3 mt-2">
<i class="fas fa-globe me-1"></i> Chrome Launch
</h6>
<div class="mb-3">
<label class="form-label fw-semibold">Production URL
<small class="text-muted fw-normal">(kiosk mode at startup)</small>
</label>
<input type="url" name="chrome_url" class="form-control"
value="{{ cfg.chrome_url if cfg else '' }}" required>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Local / Fallback URL
<small class="text-muted fw-normal">(optional)</small>
</label>
<input type="url" name="chrome_local_url" class="form-control"
value="{{ cfg.chrome_local_url or '' }}">
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Insecure Origin to Trust
<small class="text-muted fw-normal">(--unsafely-treat-insecure-origin-as-secure)</small>
</label>
<input type="text" name="chrome_insecure_origin" class="form-control"
value="{{ cfg.chrome_insecure_origin if cfg else '' }}">
</div>
<hr>
<h6 class="text-uppercase text-muted mb-3">
<i class="fas fa-id-card me-1"></i> Card API
</h6>
<div class="mb-4">
<label class="form-label fw-semibold">Base URL
<small class="text-muted fw-normal">Format: {base_url}/{device_name}/{card_id}/{0or1}/{timestamp}</small>
</label>
<input type="url" name="card_api_base_url" class="form-control"
value="{{ cfg.card_api_base_url if cfg else '' }}" required>
</div>
<hr>
<h6 class="text-uppercase text-muted mb-3">
<i class="fas fa-server me-1"></i> Server / Network
</h6>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Log Server URL</label>
<input type="url" name="server_log_url" class="form-control"
value="{{ cfg.server_log_url if cfg else '' }}">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Internet Check Host
<small class="text-muted fw-normal">(ping target)</small>
</label>
<input type="text" name="internet_check_host" class="form-control"
value="{{ cfg.internet_check_host if cfg else '' }}">
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label fw-semibold">Auto-Update Host</label>
<input type="text" name="update_host" class="form-control"
value="{{ cfg.update_host if cfg else '' }}">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Auto-Update SSH User</label>
<input type="text" name="update_user" class="form-control"
value="{{ cfg.update_user if cfg else '' }}">
</div>
</div>
<hr>
<div class="mb-3">
<label class="form-label fw-semibold">Admin Notes</label>
<textarea name="notes" class="form-control" rows="2">{{ cfg.notes or '' }}</textarea>
</div>
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i> Save Settings
</button>
<a href="{{ url_for('wmt_web.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}