Pagination on audit log

This commit is contained in:
2026-01-23 13:59:32 +01:00
parent 5ac181fdf2
commit 2da8b6b81e
7 changed files with 280 additions and 6 deletions

View File

@@ -1,18 +1,20 @@
package dev.kruhlmann.imgfloat.controller; package dev.kruhlmann.imgfloat.controller;
import dev.kruhlmann.imgfloat.model.AuditLogEntryView; import dev.kruhlmann.imgfloat.model.AuditLogEntryView;
import dev.kruhlmann.imgfloat.model.AuditLogPageView;
import dev.kruhlmann.imgfloat.model.OauthSessionUser; import dev.kruhlmann.imgfloat.model.OauthSessionUser;
import dev.kruhlmann.imgfloat.service.AuditLogService; import dev.kruhlmann.imgfloat.service.AuditLogService;
import dev.kruhlmann.imgfloat.service.AuthorizationService; import dev.kruhlmann.imgfloat.service.AuthorizationService;
import dev.kruhlmann.imgfloat.util.LogSanitizer; import dev.kruhlmann.imgfloat.util.LogSanitizer;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@@ -30,8 +32,13 @@ public class AuditLogApiController {
} }
@GetMapping @GetMapping
public List<AuditLogEntryView> listAuditEntries( public AuditLogPageView listAuditEntries(
@PathVariable("broadcaster") String broadcaster, @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 OAuth2AuthenticationToken oauthToken
) { ) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
@@ -41,6 +48,15 @@ public class AuditLogApiController {
LogSanitizer.sanitize(broadcaster), LogSanitizer.sanitize(broadcaster),
LogSanitizer.sanitize(sessionUsername) LogSanitizer.sanitize(sessionUsername)
); );
return auditLogService.listEntries(broadcaster); Page<AuditLogEntryView> 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()
);
} }
} }

View File

@@ -0,0 +1,11 @@
package dev.kruhlmann.imgfloat.model;
import java.util.List;
public record AuditLogPageView(
List<AuditLogEntryView> entries,
int page,
int size,
long totalElements,
int totalPages
) {}

View File

@@ -2,12 +2,39 @@ package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.AuditLogEntry; import dev.kruhlmann.imgfloat.model.AuditLogEntry;
import java.util.List; 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.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@Repository @Repository
public interface AuditLogRepository extends JpaRepository<AuditLogEntry, String> { public interface AuditLogRepository extends JpaRepository<AuditLogEntry, String> {
List<AuditLogEntry> findTop200ByBroadcasterOrderByCreatedAtDesc(String broadcaster); List<AuditLogEntry> 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<AuditLogEntry> searchEntries(
@Param("broadcaster") String broadcaster,
@Param("actor") String actor,
@Param("action") String action,
@Param("search") String search,
Pageable pageable
);
void deleteByBroadcaster(String broadcaster); void deleteByBroadcaster(String broadcaster);
} }

View File

@@ -6,6 +6,9 @@ import dev.kruhlmann.imgfloat.repository.AuditLogRepository;
import dev.kruhlmann.imgfloat.util.LogSanitizer; import dev.kruhlmann.imgfloat.util.LogSanitizer;
import java.util.List; import java.util.List;
import java.util.Locale; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
@@ -63,6 +66,33 @@ public class AuditLogService {
.toList(); .toList();
} }
public Page<AuditLogEntry> 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) { public void deleteEntriesForBroadcaster(String broadcaster) {
String normalizedBroadcaster = normalize(broadcaster); String normalizedBroadcaster = normalize(broadcaster);
if (normalizedBroadcaster == null || normalizedBroadcaster.isBlank()) { if (normalizedBroadcaster == null || normalizedBroadcaster.isBlank()) {
@@ -74,4 +104,9 @@ public class AuditLogService {
private String normalize(String value) { private String normalize(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT); return value == null ? null : value.toLowerCase(Locale.ROOT);
} }
private String normalizeFilter(String value) {
String normalized = normalize(value);
return normalized == null || normalized.isBlank() ? null : normalized;
}
} }

View File

@@ -2571,6 +2571,44 @@ button:disabled:hover {
margin: 0 0 6px; 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 { .audit-table-wrapper {
margin-top: 16px; margin-top: 16px;
overflow-x: auto; overflow-x: auto;
@@ -2606,6 +2644,25 @@ button:disabled:hover {
color: #cbd5e1; 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 { .control-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1,5 +1,25 @@
const auditBody = document.getElementById("audit-log-body"); const auditBody = document.getElementById("audit-log-body");
const auditEmpty = document.getElementById("audit-empty"); 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) => { const formatTimestamp = (value) => {
if (!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 = () => const loadAuditLog = () =>
fetch(`/api/channels/${encodeURIComponent(broadcaster)}/audit`) fetch(`/api/channels/${encodeURIComponent(broadcaster)}/audit?${buildQueryParams()}`)
.then((response) => { .then((response) => {
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load audit log (${response.status})`); throw new Error(`Failed to load audit log (${response.status})`);
} }
return response.json(); 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); renderEntries(entries);
updatePagination();
}) })
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
auditEmpty.textContent = "Unable to load audit entries."; auditEmpty.textContent = "Unable to load audit entries.";
auditEmpty.classList.remove("hidden"); 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(); loadAuditLog();

View File

@@ -24,9 +24,36 @@
<div class="audit-panel-header"> <div class="audit-panel-header">
<div> <div>
<h2>Recent activity</h2> <h2>Recent activity</h2>
<p class="subtle">Latest 200 entries for your channel.</p> <p class="subtle">Filter and search through audit activity for your channel.</p>
</div> </div>
</div> </div>
<form class="audit-filters" id="audit-filters">
<label class="audit-filter">
<span>Search</span>
<input type="search" id="audit-search" placeholder="Search action, actor, or details" />
</label>
<label class="audit-filter">
<span>Actor</span>
<input type="text" id="audit-actor" placeholder="Filter by actor" />
</label>
<label class="audit-filter">
<span>Action</span>
<input type="text" id="audit-action" placeholder="Filter by action" />
</label>
<label class="audit-filter">
<span>Page size</span>
<select id="audit-size">
<option value="10">10</option>
<option value="25" selected>25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</label>
<div class="audit-filter-actions">
<button type="submit" class="button">Apply filters</button>
<button type="button" class="button ghost" id="audit-clear">Clear</button>
</div>
</form>
<div class="audit-table-wrapper"> <div class="audit-table-wrapper">
<table class="audit-table"> <table class="audit-table">
<thead> <thead>
@@ -41,6 +68,13 @@
</table> </table>
<div id="audit-empty" class="audit-empty hidden">No audit entries yet.</div> <div id="audit-empty" class="audit-empty hidden">No audit entries yet.</div>
</div> </div>
<div class="audit-pagination" id="audit-pagination">
<div class="audit-pagination-info" id="audit-pagination-info"></div>
<div class="audit-pagination-actions">
<button type="button" class="button ghost" id="audit-prev">Previous</button>
<button type="button" class="button" id="audit-next">Next</button>
</div>
</div>
</section> </section>
</main> </main>
</div> </div>