diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/AuditLogApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/AuditLogApiController.java index c5bdfbf..cee1c36 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/AuditLogApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/AuditLogApiController.java @@ -1,18 +1,20 @@ package dev.kruhlmann.imgfloat.controller; import dev.kruhlmann.imgfloat.model.AuditLogEntryView; +import dev.kruhlmann.imgfloat.model.AuditLogPageView; import dev.kruhlmann.imgfloat.model.OauthSessionUser; import dev.kruhlmann.imgfloat.service.AuditLogService; import dev.kruhlmann.imgfloat.service.AuthorizationService; import dev.kruhlmann.imgfloat.util.LogSanitizer; import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import java.util.List; +import org.springframework.data.domain.Page; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -30,8 +32,13 @@ public class AuditLogApiController { } @GetMapping - public List listAuditEntries( + public AuditLogPageView listAuditEntries( @PathVariable("broadcaster") String broadcaster, + @RequestParam(name = "search", required = false) String search, + @RequestParam(name = "actor", required = false) String actor, + @RequestParam(name = "action", required = false) String action, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "25") int size, OAuth2AuthenticationToken oauthToken ) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); @@ -41,6 +48,15 @@ public class AuditLogApiController { LogSanitizer.sanitize(broadcaster), LogSanitizer.sanitize(sessionUsername) ); - return auditLogService.listEntries(broadcaster); + Page auditPage = auditLogService + .listEntries(broadcaster, actor, action, search, page, size) + .map(AuditLogEntryView::fromEntry); + return new AuditLogPageView( + auditPage.getContent(), + auditPage.getNumber(), + auditPage.getSize(), + auditPage.getTotalElements(), + auditPage.getTotalPages() + ); } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AuditLogPageView.java b/src/main/java/dev/kruhlmann/imgfloat/model/AuditLogPageView.java new file mode 100644 index 0000000..f80eac5 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AuditLogPageView.java @@ -0,0 +1,11 @@ +package dev.kruhlmann.imgfloat.model; + +import java.util.List; + +public record AuditLogPageView( + List entries, + int page, + int size, + long totalElements, + int totalPages +) {} diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/AuditLogRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/AuditLogRepository.java index b27eb79..8aeb9c5 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/repository/AuditLogRepository.java +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/AuditLogRepository.java @@ -2,12 +2,39 @@ package dev.kruhlmann.imgfloat.repository; import dev.kruhlmann.imgfloat.model.AuditLogEntry; import java.util.List; +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; @Repository public interface AuditLogRepository extends JpaRepository { List findTop200ByBroadcasterOrderByCreatedAtDesc(String broadcaster); + @Query( + """ + SELECT entry + FROM AuditLogEntry entry + WHERE entry.broadcaster = :broadcaster + AND (:actor IS NULL OR LOWER(entry.actor) = :actor) + AND (:action IS NULL OR LOWER(entry.action) LIKE CONCAT('%', :action, '%')) + AND ( + :search IS NULL + OR LOWER(entry.actor) LIKE CONCAT('%', :search, '%') + OR LOWER(entry.action) LIKE CONCAT('%', :search, '%') + OR LOWER(entry.details) LIKE CONCAT('%', :search, '%') + ) + """ + ) + Page searchEntries( + @Param("broadcaster") String broadcaster, + @Param("actor") String actor, + @Param("action") String action, + @Param("search") String search, + Pageable pageable + ); + void deleteByBroadcaster(String broadcaster); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AuditLogService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AuditLogService.java index 3e0c8e4..1494113 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/AuditLogService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AuditLogService.java @@ -6,6 +6,9 @@ import dev.kruhlmann.imgfloat.repository.AuditLogRepository; import dev.kruhlmann.imgfloat.util.LogSanitizer; import java.util.List; import java.util.Locale; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.dao.DataAccessException; @@ -63,6 +66,33 @@ public class AuditLogService { .toList(); } + public Page listEntries( + String broadcaster, + String actor, + String action, + String search, + int page, + int size + ) { + String normalizedBroadcaster = normalize(broadcaster); + if (normalizedBroadcaster == null || normalizedBroadcaster.isBlank()) { + return Page.empty(); + } + String normalizedActor = normalizeFilter(actor); + String normalizedAction = normalizeFilter(action); + String normalizedSearch = normalizeFilter(search); + int safePage = Math.max(page, 0); + int safeSize = Math.min(Math.max(size, 1), 200); + PageRequest pageRequest = PageRequest.of(safePage, safeSize, Sort.by(Sort.Direction.DESC, "createdAt")); + return auditLogRepository.searchEntries( + normalizedBroadcaster, + normalizedActor, + normalizedAction, + normalizedSearch, + pageRequest + ); + } + public void deleteEntriesForBroadcaster(String broadcaster) { String normalizedBroadcaster = normalize(broadcaster); if (normalizedBroadcaster == null || normalizedBroadcaster.isBlank()) { @@ -74,4 +104,9 @@ public class AuditLogService { private String normalize(String value) { return value == null ? null : value.toLowerCase(Locale.ROOT); } + + private String normalizeFilter(String value) { + String normalized = normalize(value); + return normalized == null || normalized.isBlank() ? null : normalized; + } } diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 763a6be..926649e 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -2571,6 +2571,44 @@ button:disabled:hover { margin: 0 0 6px; } +.audit-filters { + margin-top: 18px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px 16px; + align-items: end; +} + +.audit-filter { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #94a3b8; +} + +.audit-filter input, +.audit-filter select { + border-radius: 10px; + border: 1px solid #1f2937; + background: rgba(15, 23, 42, 0.9); + color: #e2e8f0; + padding: 10px 12px; + font-size: 14px; +} + +.audit-filter input::placeholder { + color: #64748b; +} + +.audit-filter-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + .audit-table-wrapper { margin-top: 16px; overflow-x: auto; @@ -2606,6 +2644,25 @@ button:disabled:hover { color: #cbd5e1; } +.audit-pagination { + margin-top: 16px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.audit-pagination-info { + color: #cbd5e1; + font-size: 14px; +} + +.audit-pagination-actions { + display: flex; + gap: 10px; +} + .control-list { display: flex; flex-direction: column; diff --git a/src/main/resources/static/js/audit-log.js b/src/main/resources/static/js/audit-log.js index c7e0815..ceb2e53 100644 --- a/src/main/resources/static/js/audit-log.js +++ b/src/main/resources/static/js/audit-log.js @@ -1,5 +1,25 @@ const auditBody = document.getElementById("audit-log-body"); const auditEmpty = document.getElementById("audit-empty"); +const auditFilters = document.getElementById("audit-filters"); +const auditSearch = document.getElementById("audit-search"); +const auditActor = document.getElementById("audit-actor"); +const auditAction = document.getElementById("audit-action"); +const auditSize = document.getElementById("audit-size"); +const auditClear = document.getElementById("audit-clear"); +const auditPaginationInfo = document.getElementById("audit-pagination-info"); +const auditPrev = document.getElementById("audit-prev"); +const auditNext = document.getElementById("audit-next"); + +const DEFAULT_PAGE_SIZE = 25; +const state = { + page: 0, + size: DEFAULT_PAGE_SIZE, + search: "", + actor: "", + action: "", + totalPages: 0, + totalElements: 0 +}; const formatTimestamp = (value) => { if (!value) { @@ -42,21 +62,95 @@ const renderEntries = (entries) => { }); }; +const updatePagination = () => { + const totalPages = state.totalPages || 0; + const totalElements = state.totalElements || 0; + if (totalElements === 0) { + auditPaginationInfo.textContent = "No matching audit entries."; + auditPrev.disabled = true; + auditNext.disabled = true; + return; + } + const currentPage = totalPages === 0 ? 0 : state.page + 1; + auditPaginationInfo.textContent = `Page ${currentPage} of ${totalPages} ยท ${totalElements} entries`; + auditPrev.disabled = state.page <= 0; + auditNext.disabled = totalPages === 0 || state.page >= totalPages - 1; +}; + +const buildQueryParams = () => { + const params = new URLSearchParams(); + if (state.search) { + params.set("search", state.search); + } + if (state.actor) { + params.set("actor", state.actor); + } + if (state.action) { + params.set("action", state.action); + } + params.set("page", state.page.toString()); + params.set("size", state.size.toString()); + return params.toString(); +}; + const loadAuditLog = () => - fetch(`/api/channels/${encodeURIComponent(broadcaster)}/audit`) + fetch(`/api/channels/${encodeURIComponent(broadcaster)}/audit?${buildQueryParams()}`) .then((response) => { if (!response.ok) { throw new Error(`Failed to load audit log (${response.status})`); } return response.json(); }) - .then((entries) => { + .then((payload) => { + const entries = payload.entries || []; + state.page = payload.page ?? state.page; + state.size = payload.size ?? state.size; + state.totalPages = payload.totalPages ?? 0; + state.totalElements = payload.totalElements ?? 0; renderEntries(entries); + updatePagination(); }) .catch((error) => { console.error(error); auditEmpty.textContent = "Unable to load audit entries."; auditEmpty.classList.remove("hidden"); + auditPaginationInfo.textContent = "Unable to load audit entries."; }); +const applyFilters = () => { + state.page = 0; + state.search = auditSearch.value.trim(); + state.actor = auditActor.value.trim(); + state.action = auditAction.value.trim(); + state.size = Number.parseInt(auditSize.value, 10) || DEFAULT_PAGE_SIZE; + loadAuditLog(); +}; + +auditFilters.addEventListener("submit", (event) => { + event.preventDefault(); + applyFilters(); +}); + +auditClear.addEventListener("click", () => { + auditSearch.value = ""; + auditActor.value = ""; + auditAction.value = ""; + auditSize.value = DEFAULT_PAGE_SIZE.toString(); + applyFilters(); +}); + +auditPrev.addEventListener("click", () => { + if (state.page > 0) { + state.page -= 1; + loadAuditLog(); + } +}); + +auditNext.addEventListener("click", () => { + if (state.totalPages && state.page < state.totalPages - 1) { + state.page += 1; + loadAuditLog(); + } +}); + loadAuditLog(); diff --git a/src/main/resources/templates/audit-log.html b/src/main/resources/templates/audit-log.html index 4e38ffe..518dcc8 100644 --- a/src/main/resources/templates/audit-log.html +++ b/src/main/resources/templates/audit-log.html @@ -24,9 +24,36 @@

Recent activity

-

Latest 200 entries for your channel.

+

Filter and search through audit activity for your channel.

+
+ + + + +
+ + +
+
@@ -41,6 +68,13 @@
+
+
+
+ + +
+