diff --git a/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 4c14901b3..654c78fe9 100644 --- a/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -19,6 +19,7 @@ public class RequestUriUtils { || requestURI.endsWith(".svg") || requestURI.endsWith(".png") || requestURI.endsWith(".ico") + || requestURI.endsWith(".txt") || requestURI.endsWith(".webmanifest") || requestURI.startsWith(contextPath + "/api/v1/info/status"); } @@ -35,6 +36,7 @@ public class RequestUriUtils { || requestURI.endsWith(".png") || requestURI.endsWith(".ico") || requestURI.endsWith(".css") + || requestURI.endsWith(".txt") || requestURI.endsWith(".map") || requestURI.endsWith(".svg") || requestURI.endsWith("popularity.txt") diff --git a/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java b/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java index 8ec7402d7..6d6fc2199 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java +++ b/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java @@ -13,6 +13,11 @@ import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import jakarta.servlet.http.HttpServletRequest; + +import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.config.AuditConfigurationProperties; import stirling.software.proprietary.service.AuditService; @@ -95,6 +100,14 @@ public class ControllerAuditAspect { // Get the request path String path = getRequestPath(method, httpMethod); + // Skip auditing static resources for GET requests + if ("GET".equals(httpMethod)) { + HttpServletRequest request = getCurrentRequest(); + if (request != null && RequestUriUtils.isStaticResource(request.getContextPath(), request.getRequestURI())) { + return joinPoint.proceed(); + } + } + // Create audit data Map auditData = new HashMap<>(); auditData.put("controller", joinPoint.getTarget().getClass().getSimpleName()); @@ -246,4 +259,12 @@ public class ControllerAuditAspect { // Combine base path and method path return basePath + methodPath; } + + /** + * Gets the current HttpServletRequest from the RequestContextHolder + */ + private HttpServletRequest getCurrentRequest() { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return attributes != null ? attributes.getRequest() : null; + } } \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java b/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java index b09c7f57e..3df0da54e 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java +++ b/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java @@ -5,9 +5,12 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import org.springframework.data.domain.Page; @@ -29,8 +32,10 @@ import org.springframework.web.bind.annotation.ResponseBody; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.config.AuditConfigurationProperties; import stirling.software.proprietary.model.security.PersistentAuditEvent; @@ -64,6 +69,9 @@ public class AuditDashboardController { // Add audit level enum values for display model.addAttribute("auditLevels", AuditLevel.values()); + // Add audit event types for the dropdown + model.addAttribute("auditEventTypes", AuditEventType.values()); + return "audit/dashboard"; } @@ -73,56 +81,75 @@ public class AuditDashboardController { @GetMapping("/data") @ResponseBody public Map getAuditData( - @RequestParam(value = "page", defaultValue = "0") int page, - @RequestParam(value = "size", defaultValue = "20") int size, + @RequestParam(value = "page", defaultValue = "0") Long page, + @RequestParam(value = "size", defaultValue = "30") Long size, @RequestParam(value = "type", required = false) String type, @RequestParam(value = "principal", required = false) String principal, @RequestParam(value = "startDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, @RequestParam(value = "endDate", required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { - - Pageable pageable = PageRequest.of(page, size, Sort.by("timestamp").descending()); - - // Create dynamic query based on parameters + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, HttpServletRequest request) { + + log.info("Raw query string: {}", request.getQueryString()); + + Pageable pageable = PageRequest.of(page.intValue(), size.intValue(), Sort.by("timestamp").descending()); Page events; - + + String mode = "unknown"; + if (type != null && principal != null && startDate != null && endDate != null) { + mode = "principal + type + startDate + endDate"; Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); - events = auditRepository.findByPrincipalAndTypeAndTimestampBetween( - principal, type, start, end, pageable); + events = auditRepository.findByPrincipalAndTypeAndTimestampBetween(principal, type, start, end, pageable); } else if (type != null && principal != null) { + mode = "principal + type"; events = auditRepository.findByPrincipalAndType(principal, type, pageable); } else if (type != null && startDate != null && endDate != null) { + mode = "type + startDate + endDate"; Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); events = auditRepository.findByTypeAndTimestampBetween(type, start, end, pageable); } else if (principal != null && startDate != null && endDate != null) { + mode = "principal + startDate + endDate"; Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); events = auditRepository.findByPrincipalAndTimestampBetween(principal, start, end, pageable); } else if (startDate != null && endDate != null) { + mode = "startDate + endDate"; Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); events = auditRepository.findByTimestampBetween(start, end, pageable); } else if (type != null) { + mode = "type"; events = auditRepository.findByType(type, pageable); } else if (principal != null) { + mode = "principal"; events = auditRepository.findByPrincipal(principal, pageable); } else { + mode = "all"; events = auditRepository.findAll(pageable); } - - // Format the response + + // Logging + List content = events.getContent(); + Long firstId = content.isEmpty() ? null : content.get(0).getId(); + Long lastId = content.isEmpty() ? null : content.get(content.size() - 1).getId(); + + log.info("Audit request: page={} size={} mode='{}' → result page={} totalElements={} totalPages={} contentSize={}", + page, size, mode, events.getNumber(), events.getTotalElements(), events.getTotalPages(), content.size()); + + log.info("Audit content ID range: firstId={} lastId={} (descending timestamp)", firstId, lastId); + Map response = new HashMap<>(); - response.put("content", events.getContent()); + response.put("content", content); response.put("totalPages", events.getTotalPages()); response.put("totalElements", events.getTotalElements()); response.put("currentPage", events.getNumber()); - + return response; } + /** * Get statistics for charts. @@ -160,6 +187,31 @@ public class AuditDashboardController { return stats; } + /** + * Get all unique event types from the database for filtering. + */ + @GetMapping("/types") + @ResponseBody + public List getAuditTypes() { + // Get distinct event types from the database + List results = auditRepository.findDistinctEventTypes(); + List dbTypes = results.stream() + .map(row -> (String) row[0]) + .collect(Collectors.toList()); + + // Include standard enum types in case they're not in the database yet + List enumTypes = Arrays.stream(AuditEventType.values()) + .map(AuditEventType::name) + .collect(Collectors.toList()); + + // Combine both sources, remove duplicates, and sort + Set combinedTypes = new HashSet<>(); + combinedTypes.addAll(dbTypes); + combinedTypes.addAll(enumTypes); + + return combinedTypes.stream().sorted().collect(Collectors.toList()); + } + /** * Export audit data as CSV. */ @@ -195,7 +247,7 @@ public class AuditDashboardController { Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); events = auditRepository.findByTimestampBetween(start, end); } else if (type != null) { - events = auditRepository.findByType(type); + events = auditRepository.findByTypeForExport(type); } else if (principal != null) { events = auditRepository.findByPrincipal(principal); } else { @@ -263,7 +315,7 @@ public class AuditDashboardController { Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); events = auditRepository.findByTimestampBetween(start, end); } else if (type != null) { - events = auditRepository.findByType(type); + events = auditRepository.findByTypeForExport(type); } else if (principal != null) { events = auditRepository.findByPrincipal(principal); } else { diff --git a/proprietary/src/main/java/stirling/software/proprietary/controller/AuditExampleController.java b/proprietary/src/main/java/stirling/software/proprietary/controller/AuditExampleController.java index 12dd28bda..270c24f6a 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/controller/AuditExampleController.java +++ b/proprietary/src/main/java/stirling/software/proprietary/controller/AuditExampleController.java @@ -67,7 +67,7 @@ public class AuditExampleController { /** * Example using @Audited annotation with file upload at VERBOSE level */ - @Audited(type = AuditEventType.FILE_DOWNLOAD, level = AuditLevel.VERBOSE, includeResult = true) + @Audited(type = AuditEventType.FILE_OPERATION, level = AuditLevel.VERBOSE, includeResult = true) @PostMapping("/files/process") public ResponseEntity> processFile(MultipartFile file) { // This method is automatically audited at VERBOSE level diff --git a/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java b/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java index 9c4d98ef2..7a2b1b4c5 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java +++ b/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import stirling.software.proprietary.model.security.PersistentAuditEvent; @@ -26,7 +27,8 @@ public interface PersistentAuditEventRepository // Non-paged versions for export List findByPrincipal(String principal); - List findByType(String type); + @Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type") + List findByTypeForExport(@Param("type") String type); List findByTimestampBetween(Instant startDate, Instant endDate); List findByTimestampAfter(Instant startDate); List findByPrincipalAndType(String principal, String type); @@ -50,4 +52,8 @@ public interface PersistentAuditEventRepository @Query("SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.principal") List countByPrincipal(); + + // Get distinct event types for filtering + @Query("SELECT DISTINCT e.type FROM PersistentAuditEvent e ORDER BY e.type") + List findDistinctEventTypes(); } \ No newline at end of file diff --git a/proprietary/src/main/resources/static/css/audit-dashboard.css b/proprietary/src/main/resources/static/css/audit-dashboard.css new file mode 100644 index 000000000..15bac161c --- /dev/null +++ b/proprietary/src/main/resources/static/css/audit-dashboard.css @@ -0,0 +1,140 @@ +.dashboard-card { + margin-bottom: 20px; + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} +.stat-card { + text-align: center; + padding: 20px; +} +.stat-number { + font-size: 2rem; + font-weight: bold; +} +.stat-label { + font-size: 1rem; + color: #666; +} +.chart-container { + position: relative; + height: 300px; + width: 100%; +} +.filter-card { + margin-bottom: 20px; + padding: 15px; +} +.loading-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(255, 255, 255, 0.7); + 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: #dc3545; /* Red */ +} +.level-1 { + background-color: #fd7e14; /* Orange */ +} +.level-2 { + background-color: #28a745; /* Green */ +} +.level-3 { + background-color: #17a2b8; /* Teal */ +} +/* Custom data table styling */ +.audit-table { + font-size: 0.9rem; +} +.audit-table th { + background-color: #f8f9fa; + position: sticky; + top: 0; + z-index: 10; +} +.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 #dee2e6; +} + +.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: #f8f9fa; + border-radius: 4px; + padding: 10px; + max-height: 300px; + overflow-y: auto; + font-family: monospace; + white-space: pre-wrap; +} + +/* Simple, minimal radio styling - no extras */ +.form-check { + padding: 8px 0; +} + +#debug-console { + position: fixed; + bottom: 0; + right: 0; + width: 400px; + height: 200px; + background: rgba(0,0,0,0.8); + color: #0f0; + font-family: monospace; + font-size: 12px; + z-index: 9999; + overflow-y: auto; + padding: 10px; + border: 1px solid #0f0; + 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; +} + +label.btn-outline-primary.active { + background-color: var(--bs-primary); + color: white; +} + +label.btn-outline-primary input[type="radio"] { + cursor: pointer; +} \ No newline at end of file diff --git a/proprietary/src/main/resources/static/js/audit/dashboard.js b/proprietary/src/main/resources/static/js/audit/dashboard.js new file mode 100644 index 000000000..f5cf6324c --- /dev/null +++ b/proprietary/src/main/resources/static/js/audit/dashboard.js @@ -0,0 +1,735 @@ +// 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; + +// Debug logger function +let debugEnabled = false; // Set to false by default in production +function debugLog(message, data) { + if (!debugEnabled) return; + + const console = document.getElementById('debug-console'); + if (console) { + if (console.style.display === 'none' || !console.style.display) { + console.style.display = 'block'; + } + + const time = new Date().toLocaleTimeString(); + let logMessage = `[${time}] ${message}`; + + if (data !== undefined) { + if (typeof data === 'object') { + try { + logMessage += ': ' + JSON.stringify(data); + } catch (e) { + logMessage += ': ' + data; + } + } else { + logMessage += ': ' + data; + } + } + + const logLine = document.createElement('div'); + logLine.textContent = logMessage; + console.appendChild(logLine); + console.scrollTop = console.scrollHeight; + + // Keep only last 100 lines + while (console.childNodes.length > 100) { + console.removeChild(console.firstChild); + } + } + + // Also log to browser console + if (data !== undefined) { + window.console.log(message, data); + } else { + window.console.log(message); + } +} + +// Add keyboard shortcut to toggle debug console +document.addEventListener('keydown', function(e) { + if (e.key === 'F12' && e.ctrlKey) { + const console = document.getElementById('debug-console'); + if (console) { + console.style.display = console.style.display === 'none' || !console.style.display ? 'block' : 'none'; + e.preventDefault(); + } + } +}); + +// Initialize page +document.addEventListener('DOMContentLoaded', function() { + debugLog('Page initialized'); + + // 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'); + + // Debug log DOM elements + debugLog('DOM elements initialized', { + auditTableBody: !!auditTableBody, + pageSizeSelect: !!pageSizeSelect + }); + + // Load event types for dropdowns + loadEventTypes(); + + // Show a loading message immediately + if (auditTableBody) { + auditTableBody.innerHTML = + '
Loading audit data...'; + } else { + debugLog('ERROR: auditTableBody element not found!'); + } + + // Make a direct API call first to avoid validation issues + loadAuditData(0, pageSize); + + // Load statistics for dashboard + loadStats(7); + + // 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; + debugLog('Applying filters and resetting to page 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'; + + debugLog('Resetting filters and going to page 0'); + + // 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; + + debugLog('Radio format selected', radio.value); + }); + } + }); + + // Handle export button with debug + exportButton.onclick = function(e) { + debugLog('Export button clicked'); + e.preventDefault(); + + // Get selected format with fallback + const selectedRadio = document.querySelector('input[name="exportFormat"]:checked'); + const exportFormat = selectedRadio ? selectedRadio.value : 'csv'; + + debugLog('Selected format', exportFormat); + exportAuditData(exportFormat); + return false; + }; + + // Set up pagination buttons + document.getElementById('page-first').onclick = function() { + debugLog('First page button clicked'); + if (currentPage > 0) { + goToPage(0); + } + return false; + }; + + document.getElementById('page-prev').onclick = function() { + debugLog('Previous page button clicked'); + if (currentPage > 0) { + goToPage(currentPage - 1); + } + return false; + }; + + document.getElementById('page-next').onclick = function() { + debugLog('Next page button clicked'); + if (currentPage < totalPages - 1) { + goToPage(currentPage + 1); + } + return false; + }; + + document.getElementById('page-last').onclick = function() { + debugLog('Last page button clicked'); + 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; + + debugLog('Loading audit data', { + currentPage: currentPage, + requestedPage: requestedPage, + pageSize: pageSize, + realPageSize: realPageSize + }); + + 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}`; + + debugLog('Fetching URL', url); + + // Update page indicator + if (document.getElementById('page-indicator')) { + document.getElementById('page-indicator').textContent = `Page ${requestedPage + 1} of ?`; + } + + fetch(url) + .then(response => { + debugLog('Response received', response.status); + return response.json(); + }) + .then(data => { + debugLog('Data received', { + totalPages: data.totalPages, + serverPage: data.currentPage, + totalElements: data.totalElements, + contentSize: data.content.length + }); + + // 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 + + debugLog('Pagination state updated', { + totalPages: totalPages, + currentPage: currentPage + }); + + // 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; + debugLog('Restored original page size', pageSize); + } + + // Store original page size for recovery + window.originalPageSize = realPageSize; + + // Clear busy flag + window.paginationBusy = false; + debugLog('Pagination completed successfully'); + }) + .catch(error => { + debugLog('Error loading data', error.message); + if (auditTableBody) { + auditTableBody.innerHTML = `Error loading data: ${error.message}`; + } + 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; + 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) { + debugLog('renderTable called with', events ? events.length : 0, 'events'); + + if (!events || events.length === 0) { + debugLog('No events to render'); + auditTableBody.innerHTML = 'No audit events found matching the current filters'; + return; + } + + try { + debugLog('Clearing table body'); + auditTableBody.innerHTML = ''; + + debugLog('Processing events for table'); + events.forEach((event, index) => { + try { + const row = document.createElement('tr'); + row.innerHTML = ` + ${event.id || 'N/A'} + ${formatDate(event.timestamp)} + ${escapeHtml(event.principal || 'N/A')} + ${escapeHtml(event.type || 'N/A')} + + `; + + // 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) { + debugLog('Error rendering row ' + index, rowError.message); + } + }); + + debugLog('Table rendering complete'); + } catch (e) { + debugLog('Error in renderTable', e.message); + auditTableBody.innerHTML = 'Error rendering table: ' + e.message + ''; + } +} + +// Show event details in modal +function showEventDetails(event) { + modalId.textContent = event.id; + modalPrincipal.textContent = event.principal; + modalType.textContent = event.type; + modalTimestamp.textContent = formatDate(event.timestamp); + + // Format JSON data + try { + const dataObj = JSON.parse(event.data); + modalData.textContent = JSON.stringify(dataObj, null, 2); + } catch (e) { + modalData.textContent = event.data; + } + + // Show the modal + 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) { + debugLog('goToPage called with page', page); + + // Basic validation - totalPages may not be initialized on first load + if (page < 0) { + debugLog('Invalid page', page); + return; + } + + // Skip validation against totalPages on first load + if (totalPages > 0 && page >= totalPages) { + debugLog('Page exceeds total pages', page); + return; + } + + // Simple guard flag + if (window.paginationBusy) { + debugLog('Pagination busy, ignoring request'); + return; + } + window.paginationBusy = true; + + try { + debugLog('Setting page to', page); + + // 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) { + debugLog('Error in pagination', e.message); + window.paginationBusy = false; + } +} + +// Render charts +function renderCharts(data) { + // 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); + + // Type chart + if (typeChart) { + typeChart.destroy(); + } + + const typeCtx = document.getElementById('typeChart').getContext('2d'); + typeChart = new Chart(typeCtx, { + type: 'bar', + data: { + labels: typeLabels, + datasets: [{ + label: 'Events by Type', + data: typeValues, + backgroundColor: getChartColors(typeLabels.length), + borderColor: getChartColors(typeLabels.length, 1), // Full opacity for borders + borderWidth: 1 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true + } + } + } + }); + + // User chart + if (userChart) { + userChart.destroy(); + } + + const userCtx = document.getElementById('userChart').getContext('2d'); + userChart = new Chart(userCtx, { + type: 'pie', + data: { + labels: userLabels, + datasets: [{ + label: 'Events by User', + data: userValues, + backgroundColor: getChartColors(userLabels.length), + borderWidth: 1 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false + } + }); + + // Time chart + if (timeChart) { + timeChart.destroy(); + } + + const timeCtx = document.getElementById('timeChart').getContext('2d'); + timeChart = new Chart(timeCtx, { + type: 'line', + data: { + labels: timeLabels, + datasets: [{ + label: 'Events Over Time', + data: timeValues, + backgroundColor: 'rgba(75, 192, 192, 0.2)', + borderColor: 'rgba(75, 192, 192, 1)', + tension: 0.1, + fill: true + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true + } + } + } + }); +} + +// Helper functions +function formatDate(timestamp) { + const date = new Date(timestamp); + return date.toLocaleString(); +} + +function escapeHtml(text) { + if (!text) return ''; + return text + .toString() + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\"/g, '"') + .replace(/'/g, '''); +} + +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); + }); +} + +// Function to generate a palette of colors for charts +function getChartColors(count, opacity = 0.6) { + // 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; +} \ No newline at end of file diff --git a/proprietary/src/main/resources/templates/audit/dashboard.html b/proprietary/src/main/resources/templates/audit/dashboard.html index 85c5a59f8..5eea355c6 100644 --- a/proprietary/src/main/resources/templates/audit/dashboard.html +++ b/proprietary/src/main/resources/templates/audit/dashboard.html @@ -6,99 +6,13 @@ - + + -
- +

Audit Dashboard

@@ -237,7 +151,10 @@
- +
@@ -301,11 +218,16 @@ entries + Page 1 of 1 (Total records: 0)
@@ -364,7 +286,10 @@
- +
@@ -389,15 +314,13 @@
Export Format
-
- -
@@ -524,7 +450,14 @@ + +
+ + + + +
- + + + +
\ No newline at end of file diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java b/stirling-pdf/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java index cc9daff83..05057b609 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java @@ -13,17 +13,11 @@ import jakarta.servlet.http.HttpServletResponse; public class CleanUrlInterceptor implements HandlerInterceptor { - private static final List ALLOWED_PARAMS = - Arrays.asList( - "lang", - "endpoint", - "endpoints", - "logout", - "error", - "errorOAuth", - "file", - "messageType", - "infoMessage"); + private static final List ALLOWED_PARAMS = Arrays.asList( + "lang", "endpoint", "endpoints", "logout", "error", "errorOAuth", "file", "messageType", "infoMessage", + "page", "size", "type", "principal", "startDate", "endDate" + ); + @Override public boolean preHandle(