You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
510 lines
20 KiB
510 lines
20 KiB
{% extends "base.html" %}
|
|
|
|
{% block title %}System Logs - Plutus{% endblock %}
|
|
|
|
{% block content %}
|
|
<nav class="breadcrumb" aria-label="breadcrumbs">
|
|
<ul>
|
|
<li><a href="{{ url_for('main.index') }}">Dashboard</a></li>
|
|
<li class="is-active"><a href="#" aria-current="page">System Logs</a></li>
|
|
</ul>
|
|
</nav>
|
|
|
|
<div class="level">
|
|
<div class="level-left">
|
|
<div>
|
|
<h1 class="title">System Logs</h1>
|
|
<p class="subtitle">User activity and system audit trail</p>
|
|
</div>
|
|
</div>
|
|
<div class="level-right">
|
|
<div class="field is-grouped">
|
|
<div class="control">
|
|
<button class="button is-info" onclick="exportLogs()">
|
|
<span class="icon"><i class="fas fa-download"></i></span>
|
|
<span>Export Logs</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filter Controls -->
|
|
<div class="box">
|
|
<h2 class="title is-5">
|
|
<span class="icon"><i class="fas fa-filter"></i></span>
|
|
Filters
|
|
</h2>
|
|
|
|
<div class="field is-grouped is-grouped-multiline">
|
|
<div class="control">
|
|
<label class="label is-small">Search:</label>
|
|
<div class="field has-addons">
|
|
<div class="control has-icons-left is-expanded">
|
|
<input class="input" type="text" id="searchInput" placeholder="Search logs, actions, details...">
|
|
<span class="icon is-small is-left">
|
|
<i class="fas fa-search"></i>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="control">
|
|
<label class="label is-small">User:</label>
|
|
<div class="select">
|
|
<select id="userFilter">
|
|
<option value="">All Users</option>
|
|
{% for user in users %}
|
|
<option value="{{ user.id }}">{{ user.FullName }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="control">
|
|
<label class="label is-small">Action:</label>
|
|
<div class="select">
|
|
<select id="actionFilter">
|
|
<option value="">All Actions</option>
|
|
{% for action in actions %}
|
|
<option value="{{ action }}">{{ action }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="control">
|
|
<label class="label is-small">Entity Type:</label>
|
|
<div class="select">
|
|
<select id="entityTypeFilter">
|
|
<option value="">All Types</option>
|
|
{% for entity_type in entity_types %}
|
|
<option value="{{ entity_type }}">{{ entity_type }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="control">
|
|
<label class="label is-small">Date From:</label>
|
|
<input class="input" type="date" id="dateFromFilter">
|
|
</div>
|
|
|
|
<div class="control">
|
|
<label class="label is-small">Date To:</label>
|
|
<input class="input" type="date" id="dateToFilter">
|
|
</div>
|
|
|
|
<div class="control">
|
|
<button class="button is-small is-info" onclick="applyFilters()">
|
|
<span class="icon"><i class="fas fa-search"></i></span>
|
|
<span>Apply Filters</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="control">
|
|
<button class="button is-small is-light" onclick="clearFilters()">
|
|
<span class="icon"><i class="fas fa-times"></i></span>
|
|
<span>Clear</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Results Summary -->
|
|
<div class="notification is-info is-light" id="filterResults" style="display: none;">
|
|
<span id="resultCount">0</span> of {{ logs|length }} log entries shown
|
|
</div>
|
|
|
|
<!-- Logs Table -->
|
|
<div class="box">
|
|
<h2 class="title is-5">
|
|
<span class="icon"><i class="fas fa-list"></i></span>
|
|
Log Entries
|
|
</h2>
|
|
|
|
{% if logs %}
|
|
<div class="table-container">
|
|
<table class="table is-fullwidth is-striped is-hoverable" id="logsTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Timestamp</th>
|
|
<th>User</th>
|
|
<th>Action</th>
|
|
<th>Entity</th>
|
|
<th>Details</th>
|
|
<th>IP Address</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="logsTableBody">
|
|
{% for log in logs %}
|
|
<tr>
|
|
<td>
|
|
<span class="is-size-7">{{ log.Added.strftime('%Y-%m-%d') }}</span><br>
|
|
<span class="is-size-7 has-text-grey">{{ log.Added.strftime('%H:%M:%S') }}</span>
|
|
</td>
|
|
<td>
|
|
<div class="media">
|
|
<div class="media-content">
|
|
<strong>{{ log.user_name or 'System' }}</strong>
|
|
{% if log.User_ID %}
|
|
<br><small class="has-text-grey">ID: {{ log.User_ID }}</small>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
{% if log.Action %}
|
|
<span class="tag is-info is-light">{{ log.Action }}</span>
|
|
{% else %}
|
|
<span class="has-text-grey">-</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if log.Entity_Type %}
|
|
<div>
|
|
<span class="tag is-primary is-light">{{ log.Entity_Type }}</span>
|
|
{% if log.Entity_ID %}
|
|
<br><small class="has-text-grey">ID: {{ log.Entity_ID }}</small>
|
|
{% endif %}
|
|
</div>
|
|
{% else %}
|
|
<span class="has-text-grey">-</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if log.Log_Entry %}
|
|
<div class="content is-small">
|
|
{{ log.Log_Entry[:100] }}{% if log.Log_Entry|length > 100 %}...{% endif %}
|
|
</div>
|
|
{% else %}
|
|
<span class="has-text-grey">-</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if log.IP_Address %}
|
|
<code class="is-small">{{ log.IP_Address }}</code>
|
|
{% else %}
|
|
<span class="has-text-grey">-</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<div class="buttons are-small">
|
|
<button class="button is-info is-outlined" onclick="showLogDetail({{ log.id }})">
|
|
<span class="icon"><i class="fas fa-eye"></i></span>
|
|
<span>View</span>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
{% if pagination %}
|
|
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
|
|
{% if pagination.has_prev %}
|
|
<a class="pagination-previous" href="{{ url_for('main.logs_list', page=pagination.prev_num, **request.args) }}">Previous</a>
|
|
{% else %}
|
|
<a class="pagination-previous" disabled>Previous</a>
|
|
{% endif %}
|
|
|
|
{% if pagination.has_next %}
|
|
<a class="pagination-next" href="{{ url_for('main.logs_list', page=pagination.next_num, **request.args) }}">Next page</a>
|
|
{% else %}
|
|
<a class="pagination-next" disabled>Next page</a>
|
|
{% endif %}
|
|
|
|
<ul class="pagination-list">
|
|
{% for page_num in pagination.iter_pages() %}
|
|
{% if page_num %}
|
|
{% if page_num != pagination.page %}
|
|
<li><a class="pagination-link" href="{{ url_for('main.logs_list', page=page_num, **request.args) }}">{{ page_num }}</a></li>
|
|
{% else %}
|
|
<li><a class="pagination-link is-current" href="#">{{ page_num }}</a></li>
|
|
{% endif %}
|
|
{% else %}
|
|
<li><span class="pagination-ellipsis">…</span></li>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</ul>
|
|
</nav>
|
|
{% endif %}
|
|
|
|
{% else %}
|
|
<div class="notification is-info">
|
|
<p>No log entries found.</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Log Detail Modal -->
|
|
<div class="modal" id="logDetailModal">
|
|
<div class="modal-background" onclick="hideModal('logDetailModal')"></div>
|
|
<div class="modal-card">
|
|
<header class="modal-card-head">
|
|
<p class="modal-card-title">
|
|
<span class="icon"><i class="fas fa-file-alt"></i></span>
|
|
Log Entry Details
|
|
</p>
|
|
<button class="delete" aria-label="close" onclick="hideModal('logDetailModal')"></button>
|
|
</header>
|
|
<section class="modal-card-body">
|
|
<div id="logDetailContent">
|
|
<!-- Log details will be populated here -->
|
|
</div>
|
|
</section>
|
|
<footer class="modal-card-foot">
|
|
<button class="button is-info" onclick="copyLogDetails()">
|
|
<span class="icon"><i class="fas fa-copy"></i></span>
|
|
<span>Copy Details</span>
|
|
</button>
|
|
<button class="button" onclick="hideModal('logDetailModal')">Close</button>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let allLogs = [];
|
|
let filteredLogs = [];
|
|
|
|
// Initialize logs and filters when page loads
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initializeLogs();
|
|
setupEventListeners();
|
|
});
|
|
|
|
function initializeLogs() {
|
|
const tableBody = document.getElementById('logsTableBody');
|
|
const rows = tableBody.querySelectorAll('tr');
|
|
|
|
allLogs = Array.from(rows).map(row => {
|
|
const cells = row.querySelectorAll('td');
|
|
return {
|
|
element: row,
|
|
timestamp: cells[0] ? (cells[0].textContent.trim() || '') : '',
|
|
user: cells[1] ? (cells[1].textContent.trim() || '') : '',
|
|
action: cells[2] ? (cells[2].textContent.trim() || '') : '',
|
|
entityType: cells[3] ? (cells[3].textContent.trim() || '') : '',
|
|
details: cells[4] ? (cells[4].textContent.trim() || '') : '',
|
|
ipAddress: cells[5] ? (cells[5].textContent.trim() || '') : ''
|
|
};
|
|
});
|
|
|
|
filteredLogs = [...allLogs];
|
|
updateResultCount();
|
|
}
|
|
|
|
function setupEventListeners() {
|
|
document.getElementById('searchInput').addEventListener('input', applyFilters);
|
|
document.getElementById('userFilter').addEventListener('change', applyFilters);
|
|
document.getElementById('actionFilter').addEventListener('change', applyFilters);
|
|
document.getElementById('entityTypeFilter').addEventListener('change', applyFilters);
|
|
document.getElementById('dateFromFilter').addEventListener('change', applyFilters);
|
|
document.getElementById('dateToFilter').addEventListener('change', applyFilters);
|
|
}
|
|
|
|
function applyFilters() {
|
|
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
|
const userFilter = document.getElementById('userFilter').value;
|
|
const actionFilter = document.getElementById('actionFilter').value;
|
|
const entityTypeFilter = document.getElementById('entityTypeFilter').value;
|
|
const dateFromFilter = document.getElementById('dateFromFilter').value;
|
|
const dateToFilter = document.getElementById('dateToFilter').value;
|
|
|
|
// Filter logs
|
|
filteredLogs = allLogs.filter(log => {
|
|
// Search filter
|
|
const searchMatch = !searchTerm ||
|
|
log.user.toLowerCase().includes(searchTerm) ||
|
|
log.action.toLowerCase().includes(searchTerm) ||
|
|
log.entityType.toLowerCase().includes(searchTerm) ||
|
|
log.details.toLowerCase().includes(searchTerm) ||
|
|
log.ipAddress.toLowerCase().includes(searchTerm);
|
|
|
|
// User filter
|
|
const userMatch = !userFilter || log.user.includes(`ID: ${userFilter}`);
|
|
|
|
// Action filter
|
|
const actionMatch = !actionFilter || log.action.includes(actionFilter);
|
|
|
|
// Entity type filter
|
|
const entityTypeMatch = !entityTypeFilter || log.entityType.includes(entityTypeFilter);
|
|
|
|
// Date filters would need server-side implementation for full functionality
|
|
// For now, we'll implement basic client-side date filtering on visible text
|
|
let dateMatch = true;
|
|
if (dateFromFilter || dateToFilter) {
|
|
const logDate = log.timestamp.split(' ')[0]; // Get just the date part
|
|
if (dateFromFilter && logDate < dateFromFilter) dateMatch = false;
|
|
if (dateToFilter && logDate > dateToFilter) dateMatch = false;
|
|
}
|
|
|
|
return searchMatch && userMatch && actionMatch && entityTypeMatch && dateMatch;
|
|
});
|
|
|
|
// Update display
|
|
updateTable();
|
|
updateResultCount();
|
|
}
|
|
|
|
function updateTable() {
|
|
const tableBody = document.getElementById('logsTableBody');
|
|
|
|
// Hide all rows first
|
|
allLogs.forEach(log => {
|
|
log.element.style.display = 'none';
|
|
});
|
|
|
|
// Show filtered rows
|
|
filteredLogs.forEach(log => {
|
|
log.element.style.display = '';
|
|
tableBody.appendChild(log.element); // Re-append to maintain order
|
|
});
|
|
}
|
|
|
|
function updateResultCount() {
|
|
const resultCount = document.getElementById('resultCount');
|
|
const filterResults = document.getElementById('filterResults');
|
|
|
|
resultCount.textContent = filteredLogs.length;
|
|
|
|
if (filteredLogs.length === allLogs.length) {
|
|
filterResults.style.display = 'none';
|
|
} else {
|
|
filterResults.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
function clearFilters() {
|
|
document.getElementById('searchInput').value = '';
|
|
document.getElementById('userFilter').value = '';
|
|
document.getElementById('actionFilter').value = '';
|
|
document.getElementById('entityTypeFilter').value = '';
|
|
document.getElementById('dateFromFilter').value = '';
|
|
document.getElementById('dateToFilter').value = '';
|
|
applyFilters();
|
|
}
|
|
|
|
function showLogDetail(logId) {
|
|
// Fetch log details via AJAX
|
|
fetch(`/logs/detail/${logId}`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
const log = data.log;
|
|
const detailHtml = `
|
|
<div class="content">
|
|
<table class="table is-fullwidth">
|
|
<tbody>
|
|
<tr>
|
|
<td><strong>ID</strong></td>
|
|
<td>#${log.id}</td>
|
|
</tr>
|
|
<tr>
|
|
<td><strong>Timestamp</strong></td>
|
|
<td>${log.timestamp}</td>
|
|
</tr>
|
|
<tr>
|
|
<td><strong>User</strong></td>
|
|
<td>${log.user_name || 'System'} ${log.User_ID ? `(ID: ${log.User_ID})` : ''}</td>
|
|
</tr>
|
|
<tr>
|
|
<td><strong>Action</strong></td>
|
|
<td>${log.Action || '-'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td><strong>Entity Type</strong></td>
|
|
<td>${log.Entity_Type || '-'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td><strong>Entity ID</strong></td>
|
|
<td>${log.Entity_ID || '-'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td><strong>IP Address</strong></td>
|
|
<td>${log.IP_Address || '-'}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
${log.Log_Entry ? `
|
|
<div class="field">
|
|
<label class="label">Full Details:</label>
|
|
<div class="box">
|
|
<pre class="has-text-dark">${log.Log_Entry}</pre>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('logDetailContent').innerHTML = detailHtml;
|
|
document.getElementById('logDetailModal').classList.add('is-active');
|
|
} else {
|
|
alert('Failed to load log details: ' + data.error);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error fetching log details:', error);
|
|
alert('Failed to load log details. Please try again.');
|
|
});
|
|
}
|
|
|
|
function hideModal(modalId) {
|
|
document.getElementById(modalId).classList.remove('is-active');
|
|
}
|
|
|
|
function copyLogDetails() {
|
|
const content = document.getElementById('logDetailContent').innerText;
|
|
navigator.clipboard.writeText(content).then(function() {
|
|
// Show temporary success message
|
|
const button = event.target.closest('button');
|
|
const originalText = button.innerHTML;
|
|
button.innerHTML = '<span class="icon"><i class="fas fa-check"></i></span><span>Copied!</span>';
|
|
button.classList.add('is-success');
|
|
|
|
setTimeout(function() {
|
|
button.innerHTML = originalText;
|
|
button.classList.remove('is-success');
|
|
}, 2000);
|
|
}).catch(function(err) {
|
|
console.error('Failed to copy text: ', err);
|
|
alert('Failed to copy to clipboard');
|
|
});
|
|
}
|
|
|
|
function exportLogs() {
|
|
// Create form data with current filters
|
|
const params = new URLSearchParams();
|
|
|
|
const searchTerm = document.getElementById('searchInput').value;
|
|
const userFilter = document.getElementById('userFilter').value;
|
|
const actionFilter = document.getElementById('actionFilter').value;
|
|
const entityTypeFilter = document.getElementById('entityTypeFilter').value;
|
|
const dateFromFilter = document.getElementById('dateFromFilter').value;
|
|
const dateToFilter = document.getElementById('dateToFilter').value;
|
|
|
|
if (searchTerm) params.append('search', searchTerm);
|
|
if (userFilter) params.append('user', userFilter);
|
|
if (actionFilter) params.append('action', actionFilter);
|
|
if (entityTypeFilter) params.append('entity_type', entityTypeFilter);
|
|
if (dateFromFilter) params.append('date_from', dateFromFilter);
|
|
if (dateToFilter) params.append('date_to', dateToFilter);
|
|
|
|
// Open export URL in new window
|
|
window.open(`/logs/export?${params.toString()}`, '_blank');
|
|
}
|
|
|
|
// Close modal on Escape key
|
|
document.addEventListener('keydown', function(event) {
|
|
if (event.key === 'Escape') {
|
|
const activeModals = document.querySelectorAll('.modal.is-active');
|
|
activeModals.forEach(modal => modal.classList.remove('is-active'));
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|