Auditing support (#3739)

# Description of Changes

This pull request introduces a comprehensive auditing system to the
application, along with minor updates to existing utilities and
dependencies. The most significant changes include the addition of
audit-related classes and enums, updates to the `ApplicationProperties`
model to support auditing configuration, and enhancements to utility
methods for handling static and trackable resources.

### Audit System Implementation:

* **Audit Aspect for Method Annotations**: Added `AuditAspect` to
process the new `@Audited` annotation, enabling detailed logging of
method execution, HTTP requests, and operation results based on
configurable audit levels.
(`proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java`)
* **Audit Event Types**: Introduced `AuditEventType` enum to define
standardized event types for auditing, such as authentication events,
file operations, and HTTP requests.
(`proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java`)
* **Audit Levels**: Added `AuditLevel` enum to define different levels
of audit logging (OFF, BASIC, STANDARD, VERBOSE), providing granular
control over the amount of data logged.
(`proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java`)

### Application Properties Update:

* **Audit Configuration in `ProFeatures`**: Updated the `ProFeatures`
class in `ApplicationProperties` to include support for auditing with
configurable retention days, levels, and enablement flags.
(`common/src/main/java/stirling/software/common/model/ApplicationProperties.java`)

### Utility Enhancements:

* **Static and Trackable Resource Handling**: Extended `RequestUriUtils`
methods (`isStaticResource` and `isTrackableResource`) to recognize
`.txt` files as valid static and trackable resources.
(`common/src/main/java/stirling/software/common/util/RequestUriUtils.java`)
[[1]](diffhunk://#diff-de3599037908683f2cd8f170939547612c6fc2203e9207eb4d7966508f92bbcbR22)
[[2]](diffhunk://#diff-de3599037908683f2cd8f170939547612c6fc2203e9207eb4d7966508f92bbcbR39)

### Dependency Update:

* **Spring Validation Starter**: Added `spring-boot-starter-validation`
to project dependencies to support validation mechanisms required for
auditing features. (`proprietary/build.gradle`)


Dashboard WIP

![image](https://github.com/user-attachments/assets/20d86809-63b0-44d6-82d3-bdce2ac77aa3)


![image](https://github.com/user-attachments/assets/53a5ba69-71ab-4247-9a66-7ef86e462b13)

![image](https://github.com/user-attachments/assets/9a53eaed-ebc7-463c-81da-8b1c140f8a8c)


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: a <a>
Co-authored-by: pixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com>
This commit is contained in:
Anthony Stirling
2025-06-18 13:11:36 +01:00
committed by GitHub
parent ee41dc11c2
commit 552f2ced4d
38 changed files with 4168 additions and 17 deletions

View File

@@ -0,0 +1,239 @@
.dashboard-card {
margin-bottom: 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface);
border: 1px solid var(--md-sys-color-outline-variant);
}
.card-header {
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface);
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.card-body {
background-color: var(--md-sys-color-surface-container);
}
.stat-card {
text-align: center;
padding: 20px;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
}
.stat-label {
font-size: 1rem;
color: var(--md-sys-color-on-surface-variant);
}
.chart-container {
position: relative;
height: 300px;
width: 100%;
}
.filter-card {
margin-bottom: 20px;
padding: 15px;
background-color: var(--md-sys-color-surface-container-low);
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: 4px;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--md-sys-color-surface-container-high, rgba(229, 232, 241, 0.8));
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.level-indicator {
display: inline-block;
padding: 5px 10px;
border-radius: 15px;
color: white;
font-weight: bold;
}
.level-0 {
background-color: var(--md-sys-color-error, #dc3545); /* Red */
}
.level-1 {
background-color: var(--md-sys-color-secondary, #fd7e14); /* Orange */
}
.level-2 {
background-color: var(--md-nav-section-color-other, #28a745); /* Green */
}
.level-3 {
background-color: var(--md-sys-color-tertiary, #17a2b8); /* Teal */
}
/* Custom data table styling */
.audit-table {
font-size: 0.9rem;
color: var(--md-sys-color-on-surface);
border-color: var(--md-sys-color-outline-variant);
}
.audit-table tbody tr {
background-color: var(--md-sys-color-surface-container-low);
}
.audit-table tbody tr:nth-child(even) {
background-color: var(--md-sys-color-surface-container);
}
.audit-table tbody tr:hover {
background-color: var(--md-sys-color-surface-container-high);
}
.audit-table th {
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface);
position: sticky;
top: 0;
z-index: 10;
font-weight: bold;
}
.table-responsive {
max-height: 600px;
}
.pagination-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 15px;
padding: 10px 0;
border-top: 1px solid var(--md-sys-color-outline-variant);
color: var(--md-sys-color-on-surface);
}
.pagination .page-item.active .page-link {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: white;
}
.pagination .page-link {
color: var(--bs-primary);
}
.pagination .page-link.disabled {
pointer-events: none;
color: var(--bs-secondary);
background-color: var(--bs-light);
}
.json-viewer {
background-color: var(--md-sys-color-surface-container-low);
color: var(--md-sys-color-on-surface);
border-radius: 4px;
padding: 15px;
max-height: 350px;
overflow-y: auto;
font-family: monospace;
font-size: 0.9rem;
white-space: pre-wrap;
border: 1px solid var(--md-sys-color-outline-variant);
margin-top: 10px;
}
/* Simple, minimal radio styling - no extras */
.form-check {
padding: 8px 0;
}
#debug-console {
position: fixed;
bottom: 0;
right: 0;
width: 400px;
height: 200px;
background: var(--md-sys-color-surface-container-highest, rgba(0,0,0,0.8));
color: var(--md-sys-color-tertiary, #0f0);
font-family: monospace;
font-size: 12px;
z-index: 9999;
overflow-y: auto;
padding: 10px;
border: 1px solid var(--md-sys-color-outline);
display: none; /* Changed to none by default, enable with key command */
}
/* Enhanced styling for radio buttons as buttons */
label.btn-outline-primary {
cursor: pointer;
transition: all 0.2s;
border-color: var(--md-sys-color-primary);
color: var(--md-sys-color-primary);
}
label.btn-outline-primary.active {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
border-color: var(--md-sys-color-primary);
}
label.btn-outline-primary input[type="radio"] {
cursor: pointer;
}
/* Modal overrides for dark mode */
.modal-content {
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface);
border-color: var(--md-sys-color-outline);
}
.modal-header {
border-bottom-color: var(--md-sys-color-outline-variant);
}
.modal-footer {
border-top-color: var(--md-sys-color-outline-variant);
}
/* Improved modal positioning */
.modal-dialog-centered {
display: flex;
align-items: center;
min-height: calc(100% - 3.5rem);
}
.modal {
z-index: 1050;
}
/* Button overrides for theme consistency */
.btn-outline-primary {
color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
.btn-outline-primary:hover {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
.btn-outline-secondary {
color: var(--md-sys-color-secondary);
border-color: var(--md-sys-color-secondary);
}
.btn-outline-secondary:hover {
background-color: var(--md-sys-color-secondary);
color: var(--md-sys-color-on-secondary);
}
.btn-primary {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
border-color: var(--md-sys-color-primary);
}
.btn-secondary {
background-color: var(--md-sys-color-secondary);
color: var(--md-sys-color-on-secondary);
border-color: var(--md-sys-color-secondary);
}

View File

@@ -0,0 +1,999 @@
// Initialize variables
let currentPage = 0;
let pageSize = 20;
let totalPages = 0;
let typeFilter = '';
let principalFilter = '';
let startDateFilter = '';
let endDateFilter = '';
// Charts
let typeChart;
let userChart;
let timeChart;
// DOM elements - will properly initialize these during page load
let auditTableBody;
let pageSizeSelect;
let typeFilterInput;
let exportTypeFilterInput;
let principalFilterInput;
let startDateFilterInput;
let endDateFilterInput;
let applyFiltersButton;
let resetFiltersButton;
// Initialize page
// Theme change listener to redraw charts when theme changes
function setupThemeChangeListener() {
// Watch for theme changes (usually by a class on body or html element)
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.attributeName === 'data-bs-theme' || mutation.attributeName === 'class') {
// Redraw charts with new theme colors if they exist
if (typeChart && userChart && timeChart) {
// If we have stats data cached, use it
if (window.cachedStatsData) {
renderCharts(window.cachedStatsData);
}
}
}
});
});
// Observe the document element for theme changes
observer.observe(document.documentElement, { attributes: true });
// Also observe body for class changes
observer.observe(document.body, { attributes: true });
}
document.addEventListener('DOMContentLoaded', function() {
// Initialize DOM references
auditTableBody = document.getElementById('auditTableBody');
pageSizeSelect = document.getElementById('pageSizeSelect');
typeFilterInput = document.getElementById('typeFilter');
exportTypeFilterInput = document.getElementById('exportTypeFilter');
principalFilterInput = document.getElementById('principalFilter');
startDateFilterInput = document.getElementById('startDateFilter');
endDateFilterInput = document.getElementById('endDateFilter');
applyFiltersButton = document.getElementById('applyFilters');
resetFiltersButton = document.getElementById('resetFilters');
// Load event types for dropdowns
loadEventTypes();
// Show a loading message immediately
if (auditTableBody) {
auditTableBody.innerHTML =
'<tr><td colspan="5" class="text-center"><div class="spinner-border spinner-border-sm" role="status"></div> ' + window.i18n.loading + '</td></tr>';
}
// Make a direct API call first to avoid validation issues
loadAuditData(0, pageSize);
// Load statistics for dashboard
loadStats(7);
// Setup theme change listener
setupThemeChangeListener();
// Set up event listeners
pageSizeSelect.addEventListener('change', function() {
pageSize = parseInt(this.value);
window.originalPageSize = pageSize;
currentPage = 0;
window.requestedPage = 0;
loadAuditData(0, pageSize);
});
applyFiltersButton.addEventListener('click', function() {
typeFilter = typeFilterInput.value.trim();
principalFilter = principalFilterInput.value.trim();
startDateFilter = startDateFilterInput.value;
endDateFilter = endDateFilterInput.value;
currentPage = 0;
window.requestedPage = 0;
loadAuditData(0, pageSize);
});
resetFiltersButton.addEventListener('click', function() {
// Reset input fields
typeFilterInput.value = '';
principalFilterInput.value = '';
startDateFilterInput.value = '';
endDateFilterInput.value = '';
// Reset filter variables
typeFilter = '';
principalFilter = '';
startDateFilter = '';
endDateFilter = '';
// Reset page
currentPage = 0;
window.requestedPage = 0;
// Update UI
document.getElementById('currentPage').textContent = '1';
// Load data with reset filters
loadAuditData(0, pageSize);
});
// Reset export filters button
document.getElementById('resetExportFilters').addEventListener('click', function() {
exportTypeFilter.value = '';
exportPrincipalFilter.value = '';
exportStartDateFilter.value = '';
exportEndDateFilter.value = '';
});
// Make radio buttons behave like toggle buttons
const radioLabels = document.querySelectorAll('label.btn-outline-primary');
radioLabels.forEach(label => {
const radio = label.querySelector('input[type="radio"]');
if (radio) {
// Highlight the checked radio button's label
if (radio.checked) {
label.classList.add('active');
}
// Handle clicking on the label
label.addEventListener('click', function() {
// Remove active class from all labels
radioLabels.forEach(l => l.classList.remove('active'));
// Add active class to this label
this.classList.add('active');
// Check this radio button
radio.checked = true;
});
}
});
// Handle export button
exportButton.onclick = function(e) {
e.preventDefault();
// Get selected format with fallback
const selectedRadio = document.querySelector('input[name="exportFormat"]:checked');
const exportFormat = selectedRadio ? selectedRadio.value : 'csv';
exportAuditData(exportFormat);
return false;
};
// Set up pagination buttons
document.getElementById('page-first').onclick = function() {
if (currentPage > 0) {
goToPage(0);
}
return false;
};
document.getElementById('page-prev').onclick = function() {
if (currentPage > 0) {
goToPage(currentPage - 1);
}
return false;
};
document.getElementById('page-next').onclick = function() {
if (currentPage < totalPages - 1) {
goToPage(currentPage + 1);
}
return false;
};
document.getElementById('page-last').onclick = function() {
if (totalPages > 0 && currentPage < totalPages - 1) {
goToPage(totalPages - 1);
}
return false;
};
// Set up tab change events
const tabEls = document.querySelectorAll('button[data-bs-toggle="tab"]');
tabEls.forEach(tabEl => {
tabEl.addEventListener('shown.bs.tab', function (event) {
const targetId = event.target.getAttribute('data-bs-target');
if (targetId === '#dashboard') {
// Redraw charts when dashboard tab is shown
if (typeChart) typeChart.update();
if (userChart) userChart.update();
if (timeChart) timeChart.update();
}
});
});
});
// Load audit data from server
function loadAuditData(targetPage, realPageSize) {
const requestedPage = targetPage !== undefined ? targetPage : window.requestedPage || 0;
realPageSize = realPageSize || pageSize;
showLoading('table-loading');
// Always request page 0 from server, but with increased page size if needed
let url = `/audit/data?page=${requestedPage}&size=${realPageSize}`;
if (typeFilter) url += `&type=${encodeURIComponent(typeFilter)}`;
if (principalFilter) url += `&principal=${encodeURIComponent(principalFilter)}`;
if (startDateFilter) url += `&startDate=${startDateFilter}`;
if (endDateFilter) url += `&endDate=${endDateFilter}`;
// Update page indicator
if (document.getElementById('page-indicator')) {
document.getElementById('page-indicator').textContent = `Page ${requestedPage + 1} of ?`;
}
fetch(url)
.then(response => {
return response.json();
})
.then(data => {
// Calculate the correct slice of data to show for the requested page
let displayContent = data.content;
// Render the correct slice of data
renderTable(displayContent);
// Calculate total pages based on the actual total elements
const calculatedTotalPages = Math.ceil(data.totalElements / realPageSize);
totalPages = calculatedTotalPages;
currentPage = requestedPage; // Use our tracked page, not server's
// Update UI
document.getElementById('currentPage').textContent = currentPage + 1;
document.getElementById('totalPages').textContent = totalPages;
document.getElementById('totalRecords').textContent = data.totalElements;
if (document.getElementById('page-indicator')) {
document.getElementById('page-indicator').textContent = `Page ${currentPage + 1} of ${totalPages}`;
}
// Re-enable buttons with correct state
document.getElementById('page-first').disabled = currentPage === 0;
document.getElementById('page-prev').disabled = currentPage === 0;
document.getElementById('page-next').disabled = currentPage >= totalPages - 1;
document.getElementById('page-last').disabled = currentPage >= totalPages - 1;
hideLoading('table-loading');
// Restore original page size for next operations
if (window.originalPageSize && realPageSize !== window.originalPageSize) {
pageSize = window.originalPageSize;
}
// Store original page size for recovery
window.originalPageSize = realPageSize;
// Clear busy flag
window.paginationBusy = false;
})
.catch(error => {
if (auditTableBody) {
auditTableBody.innerHTML = `<tr><td colspan="5" class="text-center">${window.i18n.errorLoading} ${error.message}</td></tr>`;
}
hideLoading('table-loading');
// Re-enable buttons
document.getElementById('page-first').disabled = false;
document.getElementById('page-prev').disabled = false;
document.getElementById('page-next').disabled = false;
document.getElementById('page-last').disabled = false;
// Clear busy flag
window.paginationBusy = false;
});
}
// Load statistics for charts
function loadStats(days) {
showLoading('type-chart-loading');
showLoading('user-chart-loading');
showLoading('time-chart-loading');
fetch(`/audit/stats?days=${days}`)
.then(response => response.json())
.then(data => {
document.getElementById('total-events').textContent = data.totalEvents;
// Cache stats data for theme changes
window.cachedStatsData = data;
renderCharts(data);
hideLoading('type-chart-loading');
hideLoading('user-chart-loading');
hideLoading('time-chart-loading');
})
.catch(error => {
console.error('Error loading stats:', error);
hideLoading('type-chart-loading');
hideLoading('user-chart-loading');
hideLoading('time-chart-loading');
});
}
// Export audit data
function exportAuditData(format) {
const type = exportTypeFilter.value.trim();
const principal = exportPrincipalFilter.value.trim();
const startDate = exportStartDateFilter.value;
const endDate = exportEndDateFilter.value;
let url = format === 'json' ? '/audit/export/json?' : '/audit/export?';
if (type) url += `&type=${encodeURIComponent(type)}`;
if (principal) url += `&principal=${encodeURIComponent(principal)}`;
if (startDate) url += `&startDate=${startDate}`;
if (endDate) url += `&endDate=${endDate}`;
// Trigger download
window.location.href = url;
}
// Render table with audit data
function renderTable(events) {
if (!events || events.length === 0) {
auditTableBody.innerHTML = '<tr><td colspan="5" class="text-center">' + window.i18n.noEventsFound + '</td></tr>';
return;
}
try {
auditTableBody.innerHTML = '';
events.forEach((event, index) => {
try {
const row = document.createElement('tr');
row.innerHTML = `
<td>${event.id || 'N/A'}</td>
<td>${formatDate(event.timestamp)}</td>
<td>${escapeHtml(event.principal || 'N/A')}</td>
<td>${escapeHtml(event.type || 'N/A')}</td>
<td><button class="btn btn-sm btn-outline-primary view-details">${window.i18n.viewDetails || 'View Details'}</button></td>
`;
// Store event data for modal
row.dataset.event = JSON.stringify(event);
// Add click handler for details button
const detailsButton = row.querySelector('.view-details');
if (detailsButton) {
detailsButton.addEventListener('click', function() {
showEventDetails(event);
});
}
auditTableBody.appendChild(row);
} catch (rowError) {
}
});
} catch (e) {
auditTableBody.innerHTML = '<tr><td colspan="5" class="text-center">' + window.i18n.errorRendering + ' ' + e.message + '</td></tr>';
}
}
// Show event details in modal
function showEventDetails(event) {
// Get modal elements by ID with correct hyphenated IDs from HTML
const modalId = document.getElementById('modal-id');
const modalPrincipal = document.getElementById('modal-principal');
const modalType = document.getElementById('modal-type');
const modalTimestamp = document.getElementById('modal-timestamp');
const modalData = document.getElementById('modal-data');
const eventDetailsModal = document.getElementById('eventDetailsModal');
// Set modal content
if (modalId) modalId.textContent = event.id;
if (modalPrincipal) modalPrincipal.textContent = event.principal;
if (modalType) modalType.textContent = event.type;
if (modalTimestamp) modalTimestamp.textContent = formatDate(event.timestamp);
// Format JSON data
if (modalData) {
try {
const dataObj = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
modalData.textContent = JSON.stringify(dataObj, null, 2);
} catch (e) {
modalData.textContent = event.data || 'No data available';
}
}
// Show the modal
if (eventDetailsModal) {
const modal = new bootstrap.Modal(eventDetailsModal);
modal.show();
}
}
// No need for a dynamic pagination renderer anymore as we're using static buttons
// Direct pagination approach - server seems to be hard-limited to returning 20 items
function goToPage(page) {
// Basic validation - totalPages may not be initialized on first load
if (page < 0) {
return;
}
// Skip validation against totalPages on first load
if (totalPages > 0 && page >= totalPages) {
return;
}
// Simple guard flag
if (window.paginationBusy) {
return;
}
window.paginationBusy = true;
try {
// Store the requested page for later
window.requestedPage = page;
currentPage = page;
// Update UI immediately for user feedback
document.getElementById('currentPage').textContent = page + 1;
// Load data with this page
loadAuditData(page, pageSize);
} catch (e) {
window.paginationBusy = false;
}
}
// Render charts
function renderCharts(data) {
// Get theme colors
const colors = getThemeColors();
// Prepare data for charts
const typeLabels = Object.keys(data.eventsByType);
const typeValues = Object.values(data.eventsByType);
const userLabels = Object.keys(data.eventsByPrincipal);
const userValues = Object.values(data.eventsByPrincipal);
// Sort days for time chart
const timeLabels = Object.keys(data.eventsByDay).sort();
const timeValues = timeLabels.map(day => data.eventsByDay[day] || 0);
// Chart.js global defaults for dark mode compatibility
Chart.defaults.color = colors.text;
Chart.defaults.borderColor = colors.grid;
// Type chart
if (typeChart) {
typeChart.destroy();
}
const typeCtx = document.getElementById('typeChart').getContext('2d');
typeChart = new Chart(typeCtx, {
type: 'bar',
data: {
labels: typeLabels,
datasets: [{
label: window.i18n.eventsByType,
data: typeValues,
backgroundColor: colors.chartColors.slice(0, typeLabels.length).map(color => {
// Add transparency to the colors
if (color.startsWith('rgb(')) {
return color.replace('rgb(', 'rgba(').replace(')', ', 0.8)');
}
return color;
}),
borderColor: colors.chartColors.slice(0, typeLabels.length),
borderWidth: 2,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
},
usePointStyle: true,
pointStyle: 'rectRounded',
boxWidth: 12,
boxHeight: 12,
}
},
tooltip: {
titleFont: {
weight: 'bold',
size: 14
},
bodyFont: {
size: 13
},
backgroundColor: colors.isDarkMode ? 'rgba(40, 44, 52, 0.9)' : 'rgba(255, 255, 255, 0.9)',
titleColor: colors.isDarkMode ? '#ffffff' : '#000000',
bodyColor: colors.isDarkMode ? '#ffffff' : '#000000',
borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.5)' : colors.grid,
borderWidth: 1,
padding: 10,
cornerRadius: 6,
callbacks: {
label: function(context) {
return `${context.dataset.label}: ${context.raw}`;
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 12
},
precision: 0 // Only show whole numbers
},
grid: {
color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid
},
title: {
display: true,
text: 'Count',
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
}
}
},
x: {
ticks: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 11
},
callback: function(value, index) {
// Get the original label
const label = this.getLabelForValue(value);
// If the label is too long, truncate it
const maxLength = 10;
if (label.length > maxLength) {
return label.substring(0, maxLength) + '...';
}
return label;
},
autoSkip: true,
maxRotation: 0,
minRotation: 0
},
grid: {
color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid,
display: false // Hide vertical gridlines for cleaner look
},
title: {
display: true,
text: 'Event Type',
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
},
padding: {top: 10, bottom: 0}
}
}
}
}
});
// User chart
if (userChart) {
userChart.destroy();
}
const userCtx = document.getElementById('userChart').getContext('2d');
userChart = new Chart(userCtx, {
type: 'pie',
data: {
labels: userLabels,
datasets: [{
label: window.i18n.eventsByUser,
data: userValues,
backgroundColor: colors.chartColors.slice(0, userLabels.length),
borderWidth: 2,
borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.5)'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
color: colors.text,
font: {
size: colors.isDarkMode ? 14 : 12,
weight: colors.isDarkMode ? 'bold' : 'normal'
},
padding: 15,
usePointStyle: true,
pointStyle: 'circle',
boxWidth: 10,
boxHeight: 10,
// Add a box around each label for better contrast in dark mode
generateLabels: function(chart) {
const original = Chart.overrides.pie.plugins.legend.labels.generateLabels;
const labels = original.call(this, chart);
if (colors.isDarkMode) {
labels.forEach(label => {
// Enhance contrast for dark mode
label.fillStyle = label.fillStyle; // Keep original fill
label.strokeStyle = 'rgba(255, 255, 255, 0.8)'; // White border
label.lineWidth = 2; // Thicker border
});
}
return labels;
}
}
},
tooltip: {
titleFont: {
weight: 'bold',
size: 14
},
bodyFont: {
size: 13
},
backgroundColor: colors.isDarkMode ? 'rgba(40, 44, 52, 0.9)' : 'rgba(255, 255, 255, 0.9)',
titleColor: colors.isDarkMode ? '#ffffff' : '#000000',
bodyColor: colors.isDarkMode ? '#ffffff' : '#000000',
borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.5)' : colors.grid,
borderWidth: 1,
padding: 10,
cornerRadius: 6
}
}
}
});
// Time chart
if (timeChart) {
timeChart.destroy();
}
const timeCtx = document.getElementById('timeChart').getContext('2d');
// Get first color for line chart with appropriate transparency
let bgColor, borderColor;
if (colors.isDarkMode) {
bgColor = 'rgba(162, 201, 255, 0.3)'; // Light blue with transparency
borderColor = 'rgb(162, 201, 255)'; // Light blue solid
} else {
bgColor = 'rgba(0, 96, 170, 0.2)'; // Dark blue with transparency
borderColor = 'rgb(0, 96, 170)'; // Dark blue solid
}
timeChart = new Chart(timeCtx, {
type: 'line',
data: {
labels: timeLabels,
datasets: [{
label: window.i18n.eventsOverTime,
data: timeValues,
backgroundColor: bgColor,
borderColor: borderColor,
borderWidth: 3,
tension: 0.2,
fill: true,
pointBackgroundColor: borderColor,
pointBorderColor: colors.isDarkMode ? '#fff' : '#000',
pointBorderWidth: 2,
pointRadius: 5,
pointHoverRadius: 7
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
},
usePointStyle: true,
pointStyle: 'line',
boxWidth: 50,
boxHeight: 3
}
},
tooltip: {
titleFont: {
weight: 'bold',
size: 14
},
bodyFont: {
size: 13
},
backgroundColor: colors.isDarkMode ? 'rgba(40, 44, 52, 0.9)' : 'rgba(255, 255, 255, 0.9)',
titleColor: colors.isDarkMode ? '#ffffff' : '#000000',
bodyColor: colors.isDarkMode ? '#ffffff' : '#000000',
borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.5)' : colors.grid,
borderWidth: 1,
padding: 10,
cornerRadius: 6,
callbacks: {
label: function(context) {
return `Events: ${context.raw}`;
}
}
}
},
interaction: {
intersect: false,
mode: 'index'
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 12
},
precision: 0 // Only show whole numbers
},
grid: {
color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid
},
title: {
display: true,
text: 'Number of Events',
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
}
}
},
x: {
ticks: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 12
},
maxRotation: 45,
minRotation: 45
},
grid: {
color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid
},
title: {
display: true,
text: 'Date',
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
},
padding: {top: 20}
}
}
}
}
});
}
// Helper functions
function formatDate(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString();
}
function escapeHtml(text) {
if (!text) return '';
return text
.toString()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function showLoading(id) {
const loading = document.getElementById(id);
if (loading) loading.style.display = 'flex';
}
function hideLoading(id) {
const loading = document.getElementById(id);
if (loading) loading.style.display = 'none';
}
// Load event types from the server for filter dropdowns
function loadEventTypes() {
fetch('/audit/types')
.then(response => response.json())
.then(types => {
if (!types || types.length === 0) {
return;
}
// Populate the type filter dropdowns
const typeFilter = document.getElementById('typeFilter');
const exportTypeFilter = document.getElementById('exportTypeFilter');
// Clear existing options except the first one (All event types)
while (typeFilter.options.length > 1) {
typeFilter.remove(1);
}
while (exportTypeFilter.options.length > 1) {
exportTypeFilter.remove(1);
}
// Add new options
types.forEach(type => {
// Main filter dropdown
const option = document.createElement('option');
option.value = type;
option.textContent = type;
typeFilter.appendChild(option);
// Export filter dropdown
const exportOption = document.createElement('option');
exportOption.value = type;
exportOption.textContent = type;
exportTypeFilter.appendChild(exportOption);
});
})
.catch(error => {
console.error('Error loading event types:', error);
});
}
// Get theme colors for charts
function getThemeColors() {
const isDarkMode = document.documentElement.getAttribute('data-bs-theme') === 'dark';
// In dark mode, use higher contrast colors for text
const textColor = isDarkMode ?
'rgb(255, 255, 255)' : // White for dark mode for maximum contrast
getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-on-surface').trim();
// Use a more visible grid color in dark mode
const gridColor = isDarkMode ?
'rgba(255, 255, 255, 0.2)' : // Semi-transparent white for dark mode
getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-outline-variant').trim();
// Define bright, high-contrast colors for both dark and light modes
const chartColorsDark = [
'rgb(162, 201, 255)', // Light blue - primary
'rgb(193, 194, 248)', // Light purple - tertiary
'rgb(255, 180, 171)', // Light red - error
'rgb(72, 189, 84)', // Green - other
'rgb(25, 177, 212)', // Cyan - convert
'rgb(25, 101, 212)', // Blue - sign
'rgb(255, 120, 146)', // Pink - security
'rgb(104, 220, 149)', // Light green - convertto
'rgb(212, 172, 25)', // Yellow - image
'rgb(245, 84, 84)', // Red - advance
];
const chartColorsLight = [
'rgb(0, 96, 170)', // Blue - primary
'rgb(88, 90, 138)', // Purple - tertiary
'rgb(186, 26, 26)', // Red - error
'rgb(72, 189, 84)', // Green - other
'rgb(25, 177, 212)', // Cyan - convert
'rgb(25, 101, 212)', // Blue - sign
'rgb(255, 120, 146)', // Pink - security
'rgb(104, 220, 149)', // Light green - convertto
'rgb(212, 172, 25)', // Yellow - image
'rgb(245, 84, 84)', // Red - advance
];
return {
text: textColor,
grid: gridColor,
backgroundColor: getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-surface-container').trim(),
chartColors: isDarkMode ? chartColorsDark : chartColorsLight,
isDarkMode: isDarkMode
};
}
// Function to generate a palette of colors for charts
function getChartColors(count, opacity = 0.6) {
try {
// Use theme colors first
const themeColors = getThemeColors();
if (themeColors && themeColors.chartColors && themeColors.chartColors.length > 0) {
const result = [];
for (let i = 0; i < count; i++) {
// Get the raw color and add opacity
let color = themeColors.chartColors[i % themeColors.chartColors.length];
// If it's rgb() format, convert to rgba()
if (color.startsWith('rgb(')) {
color = color.replace('rgb(', '').replace(')', '');
result.push(`rgba(${color}, ${opacity})`);
} else {
// Just use the color directly
result.push(color);
}
}
return result;
}
} catch (e) {
console.warn('Error using theme colors, falling back to default colors', e);
}
// Base colors - a larger palette than the default
const colors = [
[54, 162, 235], // blue
[255, 99, 132], // red
[75, 192, 192], // teal
[255, 206, 86], // yellow
[153, 102, 255], // purple
[255, 159, 64], // orange
[46, 204, 113], // green
[231, 76, 60], // dark red
[52, 152, 219], // light blue
[155, 89, 182], // violet
[241, 196, 15], // dark yellow
[26, 188, 156], // turquoise
[230, 126, 34], // dark orange
[149, 165, 166], // light gray
[243, 156, 18], // amber
[39, 174, 96], // emerald
[211, 84, 0], // dark orange red
[22, 160, 133], // green sea
[41, 128, 185], // belize hole
[142, 68, 173] // wisteria
];
const result = [];
// Always use the same format regardless of color source
if (count > colors.length) {
// Generate colors algorithmically for large sets
for (let i = 0; i < count; i++) {
// Generate a color based on position in the hue circle (0-360)
const hue = (i * 360 / count) % 360;
const sat = 70 + Math.random() * 10; // 70-80%
const light = 50 + Math.random() * 10; // 50-60%
result.push(`hsla(${hue}, ${sat}%, ${light}%, ${opacity})`);
}
} else {
// Use colors from our palette but also return in hsla format for consistency
for (let i = 0; i < count; i++) {
const color = colors[i % colors.length];
result.push(`rgba(${color[0]}, ${color[1]}, ${color[2]}, ${opacity})`);
}
}
return result;
}

View File

@@ -0,0 +1,42 @@
# Audit System Help
## About the Audit System
The Stirling PDF audit system records user actions and system events for security monitoring, compliance, and troubleshooting purposes.
## Audit Levels
| Level | Name | Description | Use Case |
|-------|------|-------------|----------|
| 0 | OFF | Minimal auditing, only critical security events | Development environments |
| 1 | BASIC | Authentication events, security events, and errors | Production environments with minimal storage |
| 2 | STANDARD | All HTTP requests and operations (default) | Normal production use |
| 3 | VERBOSE | Detailed information including headers, parameters, and results | Troubleshooting and detailed analysis |
## Configuration
Audit settings are configured in the `settings.yml` file under the `premium.proFeatures.audit` section:
```yaml
premium:
proFeatures:
audit:
enabled: true # Enable/disable audit logging
level: 2 # Audit level (0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE)
retentionDays: 90 # Number of days to retain audit logs
```
## Common Event Types
### BASIC Events:
- USER_LOGIN - User login
- USER_LOGOUT - User logout
- USER_FAILED_LOGIN - Failed login attempt
- USER_PROFILE_UPDATE - User or profile operations
### STANDARD Events:
- HTTP_REQUEST - GET requests for viewing
- PDF_PROCESS - PDF processing operations
- FILE_OPERATION - File-related operations
- SETTINGS_CHANGED - System or admin settings operations
### VERBOSE Events:
- Detailed versions of STANDARD events with parameters and results

View File

@@ -0,0 +1,250 @@
# Stirling PDF Audit System
This document provides guidance on how to use the audit system in Stirling PDF.
## Overview
The audit system provides comprehensive logging of user actions and system events, storing them in a database for later review. This is useful for:
- Security monitoring
- Compliance requirements
- User activity tracking
- Troubleshooting
## Audit Levels
The audit system supports different levels of detail that can be configured in the settings.yml file:
### Level 0: OFF
- Disables all audit logging except for critical security events
- Minimal database usage and performance impact
- Only recommended for development environments
### Level 1: BASIC
- Authentication events (login, logout, failed logins)
- Password changes
- User/role changes
- System configuration changes
- HTTP request errors (status codes >= 400)
### Level 2: STANDARD (Default)
- Everything in BASIC plus:
- All HTTP requests (basic info: URL, method, status)
- File operations (upload, download, process)
- PDF operations (view, edit, etc.)
- User operations
### Level 3: VERBOSE
- Everything in STANDARD plus:
- Request headers and parameters
- Method parameters
- Operation results
- Detailed timing information
## Configuration
Audit levels are configured in the settings.yml file under the premium section:
```yaml
premium:
proFeatures:
audit:
enabled: true # Enable/disable audit logging
level: 2 # Audit level (0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE)
retentionDays: 90 # Number of days to retain audit logs
```
## Automatic Auditing
The following events are automatically audited (based on configured level):
### HTTP Request Auditing
All HTTP requests are automatically audited with details based on the configured level:
- **BASIC level**: Only errors (status code >= 400)
- **STANDARD level**: All requests with basic information (URL, method, status code, latency, IP)
- **VERBOSE level**: All of the above plus headers, parameters, and detailed timing
### Controller Method Auditing
All controller methods with web mapping annotations are automatically audited:
- `@GetMapping`
- `@PostMapping`
- `@PutMapping`
- `@DeleteMapping`
- `@PatchMapping`
Methods with these annotations are audited at the **STANDARD** level by default.
### Security Events
The following security events are always audited at the **BASIC** level:
- Authentication events (login, logout, failed login attempts)
- Password changes
- User/role changes
## Manual Auditing
There are two ways to add audit events from your code:
### 1. Using AuditService Directly
Inject the `AuditService` and use it directly:
```java
@Service
@RequiredArgsConstructor
public class MyService {
private final AuditService auditService;
public void processPdf(MultipartFile file) {
// Process the file...
// Add an audit event with default level (STANDARD)
auditService.audit("PDF_PROCESSED", Map.of(
"filename", file.getOriginalFilename(),
"size", file.getSize(),
"operation", "process"
));
// Or specify an audit level
auditService.audit("PDF_PROCESSED_DETAILED", Map.of(
"filename", file.getOriginalFilename(),
"size", file.getSize(),
"operation", "process",
"metadata", file.getContentType(),
"user", "johndoe"
), AuditLevel.VERBOSE);
// Critical security events should use BASIC level to ensure they're always logged
auditService.audit("SECURITY_EVENT", Map.of(
"action", "file_access",
"resource", file.getOriginalFilename()
), AuditLevel.BASIC);
}
}
```
### 2. Using the @Audited Annotation
For simpler auditing, use the `@Audited` annotation on your methods:
```java
@Service
public class UserService {
// Basic audit level for important security events
@Audited(type = "USER_REGISTRATION", level = AuditLevel.BASIC)
public User registerUser(String username, String email) {
// Method implementation
User user = new User(username, email);
// Save user...
return user;
}
// Sensitive operations should use BASIC but disable argument logging
@Audited(type = "USER_PASSWORD_CHANGE", level = AuditLevel.BASIC, includeArgs = false)
public void changePassword(String username, String newPassword) {
// Change password implementation
// includeArgs=false prevents the password from being included in the audit
}
// Standard level for normal operations (default)
@Audited(type = "USER_LOGIN")
public boolean login(String username, String password) {
// Login implementation
return true;
}
// Verbose level for detailed information
@Audited(type = "USER_SEARCH", level = AuditLevel.VERBOSE, includeResult = true)
public List<User> searchUsers(String query) {
// Search implementation
// At VERBOSE level, this will include both the query and results
return userList;
}
}
```
With the `@Audited` annotation:
- You can specify the audit level using the `level` parameter
- Method arguments are automatically included in the audit event (unless `includeArgs = false`)
- Return values can be included with `includeResult = true`
- Exceptions are automatically captured and included in the audit
- The aspect handles all the boilerplate code for you
- The annotation respects the configured global audit level
### 3. Controller Automatic Auditing
In addition to the manual methods above, all controller methods with web mapping annotations are automatically audited, even without the `@Audited` annotation:
```java
@RestController
@RequestMapping("/api/users")
public class UserController {
// This method will be automatically audited
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable String id) {
// Method implementation
return ResponseEntity.ok(user);
}
// This method will be automatically audited
@PostMapping
public ResponseEntity<User> createUser(@RequestBody User user) {
// Method implementation
return ResponseEntity.ok(savedUser);
}
// This method uses @Audited and takes precedence over automatic auditing
@Audited(type = "USER_DELETE", level = AuditLevel.BASIC)
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable String id) {
// Method implementation
return ResponseEntity.noContent().build();
}
}
```
Important notes about automatic controller auditing:
- All controller methods with web mapping annotations are audited at the STANDARD level
- If a method already has an @Audited annotation, that takes precedence
- The audit event includes controller name, method name, path, and HTTP method
- At VERBOSE level, request parameters are also included
- Exceptions are automatically captured
## Common Audit Event Types
Use consistent event types throughout the application:
- `FILE_UPLOAD` - When a file is uploaded
- `FILE_DOWNLOAD` - When a file is downloaded
- `PDF_PROCESS` - When a PDF is processed (split, merged, etc.)
- `USER_CREATE` - When a user is created
- `USER_UPDATE` - When a user details are updated
- `PASSWORD_CHANGE` - When a password is changed
- `PERMISSION_CHANGE` - When permissions are modified
- `SETTINGS_CHANGE` - When system settings are changed
## Security Considerations
- Sensitive data is automatically masked in audit logs (passwords, API keys, tokens)
- Each audit event includes a unique request ID for correlation
- Audit events are stored asynchronously to avoid performance impact
- The `/auditevents` endpoint is disabled to prevent unauthorized access to audit data
## Database Storage
Audit events are stored in the `audit_events` table with the following schema:
- `id` - Unique identifier
- `principal` - The username or system identifier
- `type` - The event type
- `data` - JSON blob containing event details
- `timestamp` - When the event occurred
## Metrics
Prometheus metrics are available at `/actuator/prometheus` for monitoring system performance and audit event volume.

View File

@@ -0,0 +1,383 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title='Audit Dashboard', header='Audit Dashboard')}"></th:block>
<!-- Include Chart.js for visualizations -->
<script th:src="@{/js/thirdParty/chart.umd.min.js}"></script>
<!-- Include custom CSS -->
<link rel="stylesheet" th:href="@{/css/audit-dashboard.css}" />
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<div class="container-fluid mt-4">
<h1 class="mb-4" th:text="#{audit.dashboard.title}">Audit Dashboard</h1>
<!-- System Status Card -->
<div class="card dashboard-card mb-4">
<div class="card-header">
<h2 class="h5 mb-0" th:text="#{audit.dashboard.systemStatus}">Audit System Status</h2>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="stat-card">
<div class="stat-label" th:text="#{audit.dashboard.status}">Status</div>
<div class="stat-number">
<span th:if="${auditEnabled}" class="text-success" th:text="#{audit.dashboard.enabled}">Enabled</span>
<span th:unless="${auditEnabled}" class="text-danger" th:text="#{audit.dashboard.disabled}">Disabled</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-label" th:text="#{audit.dashboard.currentLevel}">Current Level</div>
<div class="stat-number">
<span th:class="'level-indicator level-' + ${auditLevelInt}" th:text="${auditLevel}">STANDARD</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-label" th:text="#{audit.dashboard.retentionPeriod}">Retention Period</div>
<div class="stat-number" th:text="${retentionDays} + ' ' + #{audit.dashboard.days}">90 days</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-label" th:text="#{audit.dashboard.totalEvents}">Total Events</div>
<div class="stat-number" id="total-events">-</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tabs for different sections -->
<ul class="nav nav-tabs" id="auditTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="dashboard-tab" data-bs-toggle="tab" data-bs-target="#dashboard" type="button" role="tab" aria-controls="dashboard" aria-selected="true" th:text="#{audit.dashboard.tab.dashboard}">Dashboard</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="events-tab" data-bs-toggle="tab" data-bs-target="#events" type="button" role="tab" aria-controls="events" aria-selected="false" th:text="#{audit.dashboard.tab.events}">Audit Events</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="export-tab" data-bs-toggle="tab" data-bs-target="#export" type="button" role="tab" aria-controls="export" aria-selected="false" th:text="#{audit.dashboard.tab.export}">Export</button>
</li>
<li class="nav-item" role="presentation">
</li>
</ul>
<div class="tab-content" id="auditTabsContent">
<!-- Dashboard Tab -->
<div class="tab-pane fade show active" id="dashboard" role="tabpanel" aria-labelledby="dashboard-tab">
<div class="row mt-4">
<div class="col-md-6">
<div class="card dashboard-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="h5 mb-0" th:text="#{audit.dashboard.eventsByType}">Events by Type</h3>
<div class="btn-group">
<button class="btn btn-sm btn-outline-secondary" onclick="loadStats(7)" th:text="#{audit.dashboard.period.7days}">7 Days</button>
<button class="btn btn-sm btn-outline-secondary" onclick="loadStats(30)" th:text="#{audit.dashboard.period.30days}">30 Days</button>
<button class="btn btn-sm btn-outline-secondary" onclick="loadStats(90)" th:text="#{audit.dashboard.period.90days}">90 Days</button>
</div>
</div>
<div class="card-body">
<div class="chart-container position-relative">
<div class="loading-overlay" id="type-chart-loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden" th:text="#{loading}">Loading...</span>
</div>
</div>
<canvas id="typeChart"></canvas>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card dashboard-card">
<div class="card-header">
<h3 class="h5 mb-0" th:text="#{audit.dashboard.eventsByUser}">Events by User</h3>
</div>
<div class="card-body">
<div class="chart-container position-relative">
<div class="loading-overlay" id="user-chart-loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden" th:text="#{loading}">Loading...</span>
</div>
</div>
<canvas id="userChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="card dashboard-card">
<div class="card-header">
<h3 class="h5 mb-0" th:text="#{audit.dashboard.eventsOverTime}">Events Over Time</h3>
</div>
<div class="card-body">
<div class="chart-container position-relative">
<div class="loading-overlay" id="time-chart-loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden" th:text="#{loading}">Loading...</span>
</div>
</div>
<canvas id="timeChart"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Events Tab -->
<div class="tab-pane fade" id="events" role="tabpanel" aria-labelledby="events-tab">
<div class="card dashboard-card mt-4">
<div class="card-header">
<h3 class="h5 mb-0" th:text="#{audit.dashboard.auditEvents}">Audit Events</h3>
</div>
<div class="card-body">
<!-- Filters -->
<div class="card filter-card">
<div class="row">
<div class="col-md-3">
<div class="mb-3">
<label for="typeFilter" class="form-label" th:text="#{audit.dashboard.filter.eventType}">Event Type</label>
<select class="form-select" id="typeFilter">
<option value="" th:text="#{audit.dashboard.filter.allEventTypes}">All event types</option>
<!-- Will be populated from API -->
</select>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="principalFilter" class="form-label" th:text="#{audit.dashboard.filter.user}">User</label>
<input type="text" class="form-control" id="principalFilter" th:placeholder="#{audit.dashboard.filter.userPlaceholder}" placeholder="Filter by user">
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="startDateFilter" class="form-label" th:text="#{audit.dashboard.filter.startDate}">Start Date</label>
<input type="date" class="form-control" id="startDateFilter">
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="endDateFilter" class="form-label" th:text="#{audit.dashboard.filter.endDate}">End Date</label>
<input type="date" class="form-control" id="endDateFilter">
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<button id="applyFilters" class="btn btn-primary" th:text="#{audit.dashboard.filter.apply}">Apply Filters</button>
<button id="resetFilters" class="btn btn-secondary" th:text="#{reset}">Reset</button>
</div>
</div>
</div>
<!-- Event Table -->
<div class="table-responsive position-relative">
<div class="loading-overlay" id="table-loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden" th:text="#{loading}">Loading...</span>
</div>
</div>
<table class="table table-striped table-hover audit-table">
<thead>
<tr>
<th th:text="#{audit.dashboard.table.id}">ID</th>
<th th:text="#{audit.dashboard.table.time}">Time</th>
<th th:text="#{audit.dashboard.table.user}">User</th>
<th th:text="#{audit.dashboard.table.type}">Type</th>
<th th:text="#{audit.dashboard.table.details}">Details</th>
</tr>
</thead>
<tbody id="auditTableBody">
<!-- Table rows will be populated by JavaScript -->
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination-container">
<div>
<span th:text="#{audit.dashboard.pagination.show}">Show</span>
<select id="pageSizeSelect" class="form-select form-select-sm d-inline-block w-auto mx-2">
<option value="10">10</option>
<option value="20" selected>20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<span th:text="#{audit.dashboard.pagination.entries}">entries</span>
<span class="mx-3" th:text="#{audit.dashboard.pagination.pageInfo1}">Page </span><span id="currentPage">1</span> <span th:text="#{audit.dashboard.pagination.pageInfo2}">of</span> <span id="totalPages">1</span> (<span th:text="#{audit.dashboard.pagination.totalRecords}">Total records:</span> <span id="totalRecords">0</span>)
</div>
<nav aria-label="Audit events pagination">
<div class="btn-group" role="group" aria-label="Pagination">
<button type="button" class="btn btn-outline-primary" id="page-first">&laquo;</button>
<button type="button" class="btn btn-outline-primary" id="page-prev">&lsaquo;</button>
<span class="btn btn-outline-secondary disabled" id="page-indicator">Page 1 of 1</span>
<button type="button" class="btn btn-outline-primary" id="page-next">&rsaquo;</button>
<button type="button" class="btn btn-outline-primary" id="page-last">&raquo;</button>
</div>
</nav>
</div>
</div>
</div>
<!-- Event Details Modal -->
<div class="modal fade" id="eventDetailsModal" tabindex="-1" aria-labelledby="eventDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="eventDetailsModalLabel" th:text="#{audit.dashboard.modal.eventDetails}">Event Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" th:aria-label="#{close}" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-4">
<strong th:text="#{audit.dashboard.modal.id} + ':'">ID:</strong> <span id="modal-id"></span>
</div>
<div class="col-md-4">
<strong th:text="#{audit.dashboard.modal.user} + ':'">User:</strong> <span id="modal-principal"></span>
</div>
<div class="col-md-4">
<strong th:text="#{audit.dashboard.modal.type} + ':'">Type:</strong> <span id="modal-type"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-md-12">
<strong th:text="#{audit.dashboard.modal.time} + ':'">Time:</strong> <span id="modal-timestamp"></span>
</div>
</div>
<div class="row">
<div class="col-md-12">
<strong th:text="#{audit.dashboard.modal.data} + ':'">Data:</strong>
<div class="json-viewer" id="modal-data"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" th:text="#{close}">Close</button>
</div>
</div>
</div>
</div>
</div>
<!-- Export Tab -->
<div class="tab-pane fade" id="export" role="tabpanel" aria-labelledby="export-tab">
<div class="card dashboard-card mt-4">
<div class="card-header">
<h3 class="h5 mb-0" th:text="#{audit.dashboard.export.title}">Export Audit Data</h3>
</div>
<div class="card-body">
<!-- Export Filters -->
<div class="card filter-card">
<div class="row">
<div class="col-md-3">
<div class="mb-3">
<label for="exportTypeFilter" class="form-label" th:text="#{audit.dashboard.filter.eventType}">Event Type</label>
<select class="form-select" id="exportTypeFilter">
<option value="" th:text="#{audit.dashboard.filter.allEventTypes}">All event types</option>
<!-- Will be populated from API -->
</select>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="exportPrincipalFilter" class="form-label" th:text="#{audit.dashboard.filter.user}">User</label>
<input type="text" class="form-control" id="exportPrincipalFilter" th:placeholder="#{audit.dashboard.filter.userPlaceholder}" placeholder="Filter by user">
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="exportStartDateFilter" class="form-label" th:text="#{audit.dashboard.filter.startDate}">Start Date</label>
<input type="date" class="form-control" id="exportStartDateFilter">
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="exportEndDateFilter" class="form-label" th:text="#{audit.dashboard.filter.endDate}">End Date</label>
<input type="date" class="form-control" id="exportEndDateFilter">
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-6">
<h5 th:text="#{audit.dashboard.export.format}">Export Format</h5>
<div>
<label class="btn btn-outline-primary" style="margin-right: 10px;">
<input type="radio" name="exportFormat" id="formatCSV" value="csv" checked style="margin-right: 5px;">
<span th:text="#{audit.dashboard.export.csv}">CSV (Comma Separated Values)</span>
</label>
<label class="btn btn-outline-primary">
<input type="radio" name="exportFormat" id="formatJSON" value="json" style="margin-right: 5px;">
<span th:text="#{audit.dashboard.export.json}">JSON (JavaScript Object Notation)</span>
</label>
</div>
</div>
<div class="col-md-6">
<button id="exportButton" class="btn btn-primary mt-4">
<i class="bi bi-download"></i> <span th:text="#{audit.dashboard.export.button}">Export Data</span>
</button>
<button id="resetExportFilters" class="btn btn-secondary mt-4 ms-2">
<span th:text="#{audit.dashboard.filter.reset}">Reset Filters</span>
</button>
</div>
</div>
</div>
<div class="alert alert-info mt-3">
<h5 th:text="#{audit.dashboard.export.infoTitle}">Export Information</h5>
<p th:text="#{audit.dashboard.export.infoDesc1}">The export will include all audit events matching the selected filters. For large datasets, the export may take a few moments to generate.</p>
<p th:text="#{audit.dashboard.export.infoDesc2}">Exported data will include:</p>
<ul>
<li th:text="#{audit.dashboard.export.infoItem1}">Event ID</li>
<li th:text="#{audit.dashboard.export.infoItem2}">User</li>
<li th:text="#{audit.dashboard.export.infoItem3}">Event Type</li>
<li th:text="#{audit.dashboard.export.infoItem4}">Timestamp</li>
<li th:text="#{audit.dashboard.export.infoItem5}">Event Data</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS is loaded by the common fragments -->
<script th:src="@{/js/thirdParty/jquery.min.js}"></script>
<script th:src="@{/js/thirdParty/bootstrap.min.js}"></script>
<!-- Internationalization data for JavaScript -->
<script th:inline="javascript">
window.i18n = {
loading: /*[[#{loading}]]*/ 'Loading...',
noEventsFound: /*[[#{audit.dashboard.js.noEventsFound}]]*/ 'No audit events found matching the current filters',
errorLoading: /*[[#{audit.dashboard.js.errorLoading}]]*/ 'Error loading data:',
errorRendering: /*[[#{audit.dashboard.js.errorRendering}]]*/ 'Error rendering table:',
loadingPage: /*[[#{audit.dashboard.js.loadingPage}]]*/ 'Loading page',
eventsByType: /*[[#{audit.dashboard.eventsByType}]]*/ 'Events by Type',
eventsByUser: /*[[#{audit.dashboard.eventsByUser}]]*/ 'Events by User',
eventsOverTime: /*[[#{audit.dashboard.eventsOverTime}]]*/ 'Events Over Time',
viewDetails: /*[[#{audit.dashboard.table.viewDetails}]]*/ 'View Details'
};
</script>
<!-- Load custom JavaScript -->
<script th:src="@{/js/audit/dashboard.js}"></script>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>