mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Pagination on audit log
This commit is contained in:
@@ -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()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user