From 749966a197c874d58781eb2b8806f0fdd54f91c2 Mon Sep 17 00:00:00 2001 From: Reece Date: Fri, 10 Oct 2025 13:30:15 +0100 Subject: [PATCH] Remove mantine theme --- .../api/AuditDashboardController.java | 396 ++++++++++++++++++ .../src/components/viewer/EmbedPdfViewer.tsx | 19 +- frontend/src/index.css | 9 + 3 files changed, 410 insertions(+), 14 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java new file mode 100644 index 000000000..16f860e96 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java @@ -0,0 +1,396 @@ +package stirling.software.proprietary.controller.api; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.model.api.audit.AuditDataRequest; +import stirling.software.proprietary.model.api.audit.AuditDataResponse; +import stirling.software.proprietary.model.api.audit.AuditExportRequest; +import stirling.software.proprietary.model.api.audit.AuditStatsResponse; +import stirling.software.proprietary.model.security.PersistentAuditEvent; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; +import stirling.software.proprietary.security.config.EnterpriseEndpoint; + +/** REST endpoints for the audit dashboard. */ +@Slf4j +@RestController +@RequestMapping("/api/v1/audit") +@PreAuthorize("hasRole('ROLE_ADMIN')") +@RequiredArgsConstructor +@EnterpriseEndpoint +@Tag(name = "Audit", description = "Only Enterprise - Audit related operations") +public class AuditDashboardController { + + private final PersistentAuditEventRepository auditRepository; + private final ObjectMapper objectMapper; + + /** Get audit events data for the dashboard tables. */ + @GetMapping("/data") + @Operation(summary = "Get audit events data") + public AuditDataResponse getAuditData(@ParameterObject AuditDataRequest request) { + + Pageable pageable = + PageRequest.of( + request.getPage(), request.getSize(), Sort.by("timestamp").descending()); + Page events; + + String type = request.getType(); + String principal = request.getPrincipal(); + LocalDate startDate = request.getStartDate(); + LocalDate endDate = request.getEndDate(); + + if (type != null && principal != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = + auditRepository.findByPrincipalAndTypeAndTimestampBetween( + principal, type, start, end, pageable); + } else if (type != null && principal != null) { + events = auditRepository.findByPrincipalAndType(principal, type, pageable); + } else if (type != null && startDate != null && endDate != null) { + 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) { + 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) { + 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) { + events = auditRepository.findByType(type, pageable); + } else if (principal != null) { + events = auditRepository.findByPrincipal(principal, pageable); + } else { + events = auditRepository.findAll(pageable); + } + + // Logging + List content = events.getContent(); + + return new AuditDataResponse( + content, events.getTotalPages(), events.getTotalElements(), events.getNumber()); + } + + /** Get statistics for charts (last X days). Existing behavior preserved. */ + @GetMapping("/stats") + @Operation(summary = "Get audit statistics for the last N days") + public AuditStatsResponse getAuditStats( + @Schema( + description = "Number of days to look back for audit events", + example = "7", + required = true) + @RequestParam(value = "days", defaultValue = "7") + int days) { + + // Get events from the last X days + Instant startDate = Instant.now().minus(java.time.Duration.ofDays(days)); + List events = auditRepository.findByTimestampAfter(startDate); + + // Count events by type + Map eventsByType = + events.stream() + .collect( + Collectors.groupingBy( + PersistentAuditEvent::getType, Collectors.counting())); + + // Count events by principal + Map eventsByPrincipal = + events.stream() + .collect( + Collectors.groupingBy( + PersistentAuditEvent::getPrincipal, Collectors.counting())); + + // Count events by day + Map eventsByDay = + events.stream() + .collect( + Collectors.groupingBy( + e -> + LocalDateTime.ofInstant( + e.getTimestamp(), + ZoneId.systemDefault()) + .format(DateTimeFormatter.ISO_LOCAL_DATE), + Collectors.counting())); + + return new AuditStatsResponse(eventsByType, eventsByPrincipal, eventsByDay, events.size()); + } + + // /** Advanced statistics using repository aggregations, with explicit date range. */ + // @GetMapping("/stats/range") + // @Operation(summary = "Get audit statistics for a date range (aggregated in DB)") + // public Map getAuditStatsRange(@ParameterObject AuditDateExportRequest + // request) { + + // LocalDate startDate = request.getStartDate(); + // LocalDate endDate = request.getEndDate(); + // Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + // Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + + // Map byType = toStringLongMap(auditRepository.countByTypeBetween(start, + // end)); + // Map byPrincipal = + // toStringLongMap(auditRepository.countByPrincipalBetween(start, end)); + + // Map byDay = new HashMap<>(); + // for (Object[] row : auditRepository.histogramByDayBetween(start, end)) { + // int y = ((Number) row[0]).intValue(); + // int m = ((Number) row[1]).intValue(); + // int d = ((Number) row[2]).intValue(); + // long count = ((Number) row[3]).longValue(); + // String key = String.format("%04d-%02d-%02d", y, m, d); + // byDay.put(key, count); + // } + + // Map byHour = new HashMap<>(); + // for (Object[] row : auditRepository.histogramByHourBetween(start, end)) { + // int hour = ((Number) row[0]).intValue(); + // long count = ((Number) row[1]).longValue(); + // byHour.put(String.format("%02d:00", hour), count); + // } + + // Map payload = new HashMap<>(); + // payload.put("byType", byType); + // payload.put("byPrincipal", byPrincipal); + // payload.put("byDay", byDay); + // payload.put("byHour", byHour); + // return payload; + // } + + /** Get all unique event types from the database for filtering. */ + @GetMapping("/types") + @Operation(summary = "Get all unique audit event types") + public List getAuditTypes() { + // Get distinct event types from the database + List dbTypes = auditRepository.findDistinctEventTypes(); + + // 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. */ + @GetMapping("/export/csv") + @Operation(summary = "Export audit data as CSV") + public ResponseEntity exportAuditData(@ParameterObject AuditExportRequest request) { + + List events = getAuditEventsByCriteria(request); + + // Convert to CSV + StringBuilder csv = new StringBuilder(); + csv.append("ID,Principal,Type,Timestamp,Data\n"); + + DateTimeFormatter formatter = DateTimeFormatter.ISO_INSTANT; + + for (PersistentAuditEvent event : events) { + csv.append(event.getId()).append(","); + csv.append(escapeCSV(event.getPrincipal())).append(","); + csv.append(escapeCSV(event.getType())).append(","); + csv.append(formatter.format(event.getTimestamp())).append(","); + csv.append(escapeCSV(event.getData())).append("\n"); + } + + byte[] csvBytes = csv.toString().getBytes(); + + // Set up HTTP headers for download + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + headers.setContentDispositionFormData("attachment", "audit_export.csv"); + + return ResponseEntity.ok().headers(headers).body(csvBytes); + } + + /** Export audit data as JSON. */ + @GetMapping("/export/json") + @Operation(summary = "Export audit data as JSON") + public ResponseEntity exportAuditDataJson(@ParameterObject AuditExportRequest request) { + + List events = getAuditEventsByCriteria(request); + + // Convert to JSON + try { + byte[] jsonBytes = objectMapper.writeValueAsBytes(events); + + // Set up HTTP headers for download + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setContentDispositionFormData("attachment", "audit_export.json"); + + return ResponseEntity.ok().headers(headers).body(jsonBytes); + } catch (JsonProcessingException e) { + log.error("Error serializing audit events to JSON", e); + return ResponseEntity.internalServerError().build(); + } + } + + // /** Get all unique principals. */ + // @GetMapping("/principals") + // @Operation(summary = "Get all distinct principals") + // public List getPrincipals() { + // return auditRepository.findDistinctPrincipals(); + // } + + // /** Get principals by event type. */ + // @GetMapping("/types/{type}/principals") + // @Operation(summary = "Get distinct principals for a given type") + // public List getPrincipalsByType(@PathVariable("type") String type) { + // return auditRepository.findDistinctPrincipalsByType(type); + // } + + // /** Latest helpers */ + // @GetMapping("/latest") + // @Operation(summary = "Get the latest audit event, optionally filtered by type or principal") + // public ResponseEntity getLatest( + // @RequestParam(value = "type", required = false) String type, + // @RequestParam(value = "principal", required = false) String principal) { + // if (type != null) { + // return auditRepository + // .findTopByTypeOrderByTimestampDesc(type) + // .map(ResponseEntity::ok) + // .orElse(ResponseEntity.noContent().build()); + // } else if (principal != null) { + // return auditRepository + // .findTopByPrincipalOrderByTimestampDesc(principal) + // .map(ResponseEntity::ok) + // .orElse(ResponseEntity.noContent().build()); + // } + // return auditRepository + // .findTopByOrderByTimestampDesc() + // .map(ResponseEntity::ok) + // .orElse(ResponseEntity.noContent().build()); + // } + + /** Cleanup endpoints data before a certain date */ + @DeleteMapping("/cleanup/before") + @Operation( + summary = "Cleanup audit events before a certain date", + description = "Deletes all audit events before the specified date.") + public Map cleanupBefore( + @RequestParam(value = "date", required = true) + @Schema( + description = "The cutoff date for cleanup", + example = "2025-01-01", + format = "date") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate date) { + if (date != null && !date.isAfter(LocalDate.now())) { + Instant cutoff = date.atStartOfDay(ZoneId.systemDefault()).toInstant(); + int deleted = auditRepository.deleteByTimestampBefore(cutoff); + return Map.of("deleted", deleted, "cutoffDate", date.toString()); + } + return Map.of( + "error", + "Invalid date format. Use ISO date format (YYYY-MM-DD). Date must be in the past."); + } + + // // ===== Helpers ===== + + // private Map toStringLongMap(List rows) { + // Map map = new HashMap<>(); + // for (Object[] row : rows) { + // String key = String.valueOf(row[0]); + // long val = ((Number) row[1]).longValue(); + // map.put(key, val); + // } + // return map; + // } + + /** Helper method to escape CSV fields. */ + private String escapeCSV(String field) { + if (field == null) { + return ""; + } + // Replace double quotes with two double quotes and wrap in quotes + return "\"" + field.replace("\"", "\"\"") + "\""; + } + + private List getAuditEventsByCriteria(AuditExportRequest request) { + String type = request.getType(); + String principal = request.getPrincipal(); + LocalDate startDate = request.getStartDate(); + LocalDate endDate = request.getEndDate(); + + // Get data with same filtering as getAuditData + List events; + + if (type != null && principal != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = + auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport( + principal, type, start, end); + } else if (type != null && principal != null) { + events = auditRepository.findAllByPrincipalAndTypeForExport(principal, type); + } else if (type != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findAllByTypeAndTimestampBetweenForExport(type, start, end); + } else if (principal != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = + auditRepository.findAllByPrincipalAndTimestampBetweenForExport( + principal, start, end); + } else if (startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findAllByTimestampBetweenForExport(start, end); + } else if (type != null) { + events = auditRepository.findByTypeForExport(type); + } else if (principal != null) { + events = auditRepository.findAllByPrincipalForExport(principal); + } else { + events = auditRepository.findAll(); + } + return events; + } +} diff --git a/frontend/src/components/viewer/EmbedPdfViewer.tsx b/frontend/src/components/viewer/EmbedPdfViewer.tsx index 5cc5375b4..6bd6dc96d 100644 --- a/frontend/src/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/components/viewer/EmbedPdfViewer.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Box, Center, Text, ActionIcon, Tabs, Collapse, Group, Button, Tooltip } from '@mantine/core'; -import { useMantineTheme, useMantineColorScheme } from '@mantine/core'; +import { useMantineColorScheme } from '@mantine/core'; import CloseIcon from '@mui/icons-material/Close'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; @@ -32,7 +32,6 @@ const EmbedPdfViewerContent = ({ previewFile, }: EmbedPdfViewerProps) => { const { t } = useTranslation(); - const theme = useMantineTheme(); const { colorScheme } = useMantineColorScheme(); const viewerRef = React.useRef(null); const [isViewerHovered, setIsViewerHovered] = React.useState(false); @@ -305,7 +304,7 @@ const EmbedPdfViewerContent = ({ borderRight: '1px solid var(--border-subtle)', borderBottom: '1px solid var(--border-subtle)', borderRadius: '0 0 8px 0', - boxShadow: theme.shadows.md + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)' }} > setActiveFileIndex(parseInt(value || '0'))} variant="pills" orientation="vertical" - styles={(theme) => ({ - tab: { - justifyContent: 'flex-start', - '&[data-active]': { - backgroundColor: 'rgba(147, 197, 253, 0.8)', - }, - }, - })} + classNames={{ + tab: 'viewer-file-tab' + }} > {activeFiles.map((file, index) => { @@ -358,9 +352,6 @@ const EmbedPdfViewerContent = ({ > diff --git a/frontend/src/index.css b/frontend/src/index.css index b4bb41b3e..760ea5d39 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -52,3 +52,12 @@ code { color: var(--mantine-color-blue-8); text-decoration: underline; } + +/* Viewer file tabs */ +.viewer-file-tab { + justify-content: flex-start; +} + +.viewer-file-tab[data-active] { + background-color: rgba(147, 197, 253, 0.8); +}