This commit is contained in:
Francesco 2023-09-23 16:17:57 +02:00
parent 6597a228f0
commit 49a9d58421
19 changed files with 441 additions and 96 deletions

View File

@ -88,6 +88,18 @@ public class DbAdmin {
});
}
/**
* Finds a schema by its table name
* @param tableName the table name on the database
* @return
* @throws DbAdminException if corresponding schema not found
*/
public DbObjectSchema findSchemaByTableName(String tableName) {
return schemas.stream().filter(s -> s.getTableName().equals(tableName)).findFirst().orElseThrow(() -> {
return new DbAdminException("Schema " + tableName + " not found.");
});
}
/**
* Finds a schema by its class
* @param klass

View File

@ -11,6 +11,7 @@ import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.env.Environment;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
@ -19,6 +20,8 @@ import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import tech.ailef.dbadmin.internal.InternalDbAdminConfiguration;
@ConditionalOnProperty(name = "dbadmin.enabled", matchIfMissing = true)
@ComponentScan
@EnableConfigurationProperties(DbAdminProperties.class)
@ -29,6 +32,7 @@ import org.springframework.transaction.annotation.EnableTransactionManagement;
basePackages = { "tech.ailef.dbadmin.internal.repository" }
)
@EnableTransactionManagement
@Import(InternalDbAdminConfiguration.class)
public class DbAdminAutoConfiguration {
@Autowired
Environment env;

View File

@ -13,7 +13,6 @@ import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.UncategorizedSQLException;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
@ -34,12 +33,13 @@ import tech.ailef.dbadmin.external.dbmapping.DbAdminRepository;
import tech.ailef.dbadmin.external.dbmapping.DbObject;
import tech.ailef.dbadmin.external.dbmapping.DbObjectSchema;
import tech.ailef.dbadmin.external.dto.CompareOperator;
import tech.ailef.dbadmin.external.dto.LogsSearchRequest;
import tech.ailef.dbadmin.external.dto.PaginatedResult;
import tech.ailef.dbadmin.external.dto.QueryFilter;
import tech.ailef.dbadmin.external.exceptions.InvalidPageException;
import tech.ailef.dbadmin.external.misc.Utils;
import tech.ailef.dbadmin.internal.model.UserAction;
import tech.ailef.dbadmin.internal.repository.ActionRepository;
import tech.ailef.dbadmin.internal.service.UserActionService;
/**
* The main DbAdmin controller that register most of the routes of the web interface.
@ -57,7 +57,14 @@ public class DefaultDbAdminController {
private DbAdmin dbAdmin;
@Autowired
private ActionRepository repo;
private UserActionService userActionService;
// @Autowired
// private ActionRepository repo;
//
// @Autowired
// private CustomActionRepositoryImpl customRepo;
/**
* Home page with list of schemas
@ -299,7 +306,11 @@ public class DefaultDbAdminController {
if (countDeleted > 0)
attr.addFlashAttribute("message", "Deleted " + countDeleted + " of " + ids.length + " items");
saveAction(new UserAction(schema.getTableName(), String.join(", ", ids), "DELETE"));
for (String id : ids) {
saveAction(new UserAction(schema.getTableName(), id, "DELETE"));
}
return "redirect:/" + properties.getBaseUrl() + "/model/" + className;
}
@ -422,8 +433,19 @@ public class DefaultDbAdminController {
}
@GetMapping("/logs")
public String logs(Model model) {
model.addAttribute("logs", repo.findAll());
public String logs(Model model, LogsSearchRequest searchRequest) {
model.addAttribute("activePage", "logs");
model.addAttribute(
"page",
userActionService.findActions(
searchRequest.getTable(),
searchRequest.getActionType(),
searchRequest.getItemId(),
searchRequest.toPageRequest()
)
);
model.addAttribute("schemas", dbAdmin.getSchemas());
model.addAttribute("searchRequest", searchRequest);
return "logs";
}
@ -434,8 +456,8 @@ public class DefaultDbAdminController {
return "settings";
}
@Transactional("internalTransactionManager")
// @Transactional("internalTransactionManager")
private UserAction saveAction(UserAction action) {
return repo.save(action);
return userActionService.save(action);
}
}

View File

@ -36,7 +36,12 @@ public class GlobalController {
* @return
*/
@ModelAttribute("baseUrl")
public String getBaseUrl(HttpServletRequest request) {
public String getBaseUrl() {
return props.getBaseUrl();
}
@ModelAttribute("requestUrl")
public String getRequestUrl(HttpServletRequest request) {
return request.getRequestURI();
}
}

View File

@ -117,7 +117,7 @@ public class DbAdminRepository {
}
return new PaginatedResult(
return new PaginatedResult<DbObject>(
new PaginationInfo(page, maxPage, pageSize, maxElement, null, new HashSet<>()),
results
);
@ -227,7 +227,7 @@ public class DbAdminRepository {
throw new InvalidPageException();
}
return new PaginatedResult(
return new PaginatedResult<DbObject>(
new PaginationInfo(page, maxPage, pageSize, maxElement, query, queryFilters),
jpaRepository.search(query, page, pageSize, sortKey, sortOrder, queryFilters).stream()
.map(o -> new DbObject(o, schema))

View File

@ -1,38 +0,0 @@
package tech.ailef.dbadmin.external.dto;
//package tech.ailef.dbadmin.dto;
//
//import java.util.Set;
//
//public class ListModelRequest {
// private String className;
//
// private String query;
//
// private Integer page;
//
// private Integer pageSize;
//
// private String sortKey;
//
// private String sortOrder;
//
// private Set<QueryFilter> queryFilters;
//
// private PaginationInfo paginationInfo;
//
// public ListModelRequest(String className, String query, Integer page, Integer pageSize, String sortKey,
// String sortOrder, Set<QueryFilter> queryFilters, PaginationInfo paginationInfo) {
// super();
// this.className = className;
// this.query = query;
// this.page = page;
// this.pageSize = pageSize;
// this.sortKey = sortKey;
// this.sortOrder = sortOrder;
// this.queryFilters = queryFilters;
// this.paginationInfo = paginationInfo;
// }
//
//
//// @RequestParam MultiValueMap<String, String> otherParams,
//}

View File

@ -0,0 +1,98 @@
package tech.ailef.dbadmin.external.dto;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
public class LogsSearchRequest {
private String table;
private String actionType;
private String itemId;
private int page;
private int pageSize;
private String sortKey;
private String sortOrder;
public String getTable() {
return table == null || table.isBlank() || table.equalsIgnoreCase("Any") ? null : table;
}
public void setTable(String table) {
this.table = table;
}
public String getActionType() {
return actionType == null || actionType.isBlank() || actionType.equalsIgnoreCase("Any") ? null : actionType;
}
public void setActionType(String actionType) {
this.actionType = actionType;
}
public String getItemId() {
return itemId == null || itemId.isBlank() ? null : itemId;
}
public void setItemId(String itemId) {
this.itemId = itemId;
}
public int getPage() {
return page;
}
public void setPage(int page) {
this.page = page;
}
public int getPageSize() {
return pageSize;
}
public void setPageSize(int pageSize) {
this.pageSize = pageSize;
}
public String getSortKey() {
return sortKey;
}
public void setSortKey(String sortKey) {
this.sortKey = sortKey;
}
public String getSortOrder() {
return sortOrder;
}
public void setSortOrder(String sortOrder) {
this.sortOrder = sortOrder;
}
@Override
public String toString() {
return "LogsSearchRequest [table=" + table + ", actionType=" + actionType + ", itemId=" + itemId + ", page="
+ page + ", pageSize=" + pageSize + ", sortKey=" + sortKey + ", sortOrder=" + sortOrder + "]";
}
public PageRequest toPageRequest() {
int actualPage = page - 1 < 0 ? 0 : page - 1;
int actualPageSize = pageSize <= 0 ? 50 : pageSize;
if (sortKey == null)
return PageRequest.of(actualPage, actualPageSize);
if (sortOrder == null) sortOrder = "ASC";
if (sortOrder.equals("DESC")) {
return PageRequest.of(actualPage, actualPageSize, Sort.by(sortKey).descending());
} else {
return PageRequest.of(actualPage, actualPageSize, Sort.by(sortKey).ascending());
}
}
}

View File

@ -4,12 +4,12 @@ import java.util.List;
import tech.ailef.dbadmin.external.dbmapping.DbObject;
public class PaginatedResult {
public class PaginatedResult<T> {
private PaginationInfo pagination;
private List<DbObject> results;
private List<T> results;
public PaginatedResult(PaginationInfo pagination, List<DbObject> page) {
public PaginatedResult(PaginationInfo pagination, List<T> page) {
this.pagination = pagination;
this.results = page;
}
@ -18,11 +18,15 @@ public class PaginatedResult {
return pagination;
}
public List<DbObject> getResults() {
public List<T> getResults() {
return results;
}
public int getActualResults() {
public boolean isEmpty() {
return results.isEmpty();
}
public int getNumberOfResults() {
return getResults().size();
}

View File

@ -35,6 +35,7 @@ public class PaginationInfo {
*/
private int pageSize;
// TODO: Check if used
private long maxElement;
private Set<QueryFilter> queryFilters;

View File

@ -24,6 +24,8 @@ public interface Utils {
public static MultiValueMap<String, String> computeParams(Set<QueryFilter> filters) {
MultiValueMap<String, String> r = new LinkedMultiValueMap<>();
if (filters == null)
return r;
r.put("filter_field", new ArrayList<>());
r.put("filter_op", new ArrayList<>());

View File

@ -0,0 +1,12 @@
package tech.ailef.dbadmin.internal;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@ConditionalOnProperty(name = "dbadmin.enabled", matchIfMissing = true)
@ComponentScan
@Configuration
public class InternalDbAdminConfiguration {
}

View File

@ -2,6 +2,8 @@ package tech.ailef.dbadmin.internal.model;
import java.time.LocalDateTime;
import org.springframework.format.datetime.standard.DateTimeFormatterFactory;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
@ -90,4 +92,7 @@ public class UserAction {
this.actionType = actionType;
}
public String getFormattedDate() {
return new DateTimeFormatterFactory("YYYY-MM-dd HH:mm:ss").createDateTimeFormatter().format(createdAt);
}
}

View File

@ -1,11 +1,15 @@
package tech.ailef.dbadmin.internal.repository;
import java.util.List;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import tech.ailef.dbadmin.internal.model.UserAction;
@Repository
public interface ActionRepository extends JpaRepository<UserAction, Integer> {
public interface ActionRepository extends JpaRepository<UserAction, Integer>, CustomActionRepository {
public List<UserAction> findAllByOnTableAndActionTypeAndPrimaryKey(String table, String actionType, String primaryKey, PageRequest pageRequest);
}

View File

@ -0,0 +1,14 @@
package tech.ailef.dbadmin.internal.repository;
import java.util.List;
import org.springframework.data.domain.PageRequest;
import tech.ailef.dbadmin.internal.model.UserAction;
public interface CustomActionRepository {
public List<UserAction> findActions(String table, String actionType, String itemId, PageRequest pageRequest);
public long countActions(String table, String actionType, String itemId);
}

View File

@ -0,0 +1,82 @@
package tech.ailef.dbadmin.internal.repository;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Component;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import tech.ailef.dbadmin.external.DbAdmin;
import tech.ailef.dbadmin.external.dbmapping.DbObjectSchema;
import tech.ailef.dbadmin.internal.model.UserAction;
@Component
public class CustomActionRepositoryImpl implements CustomActionRepository {
@PersistenceContext(unitName = "internal")
private EntityManager entityManager;
@Autowired
private DbAdmin dbAdmin;
@Override
public List<UserAction> findActions(String table, String actionType, String itemId, PageRequest page) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<UserAction> query = cb.createQuery(UserAction.class);
Root<UserAction> userAction = query.from(UserAction.class);
List<Predicate> predicates = new ArrayList<Predicate>();
if (table != null)
predicates.add(cb.equal(userAction.get("onTable"), table));
if (actionType != null)
predicates.add(cb.equal(userAction.get("actionType"), actionType));
if (itemId != null)
predicates.add(cb.equal(userAction.get("primaryKey"), itemId));
if (!predicates.isEmpty()) {
query.select(userAction)
.where(cb.and(
predicates.toArray(new Predicate[predicates.size()])));
}
return entityManager.createQuery(query)
.setMaxResults(page.getPageSize())
.setFirstResult((int)page.getOffset())
.getResultList();
}
@Override
public long countActions(String table, String actionType, String itemId) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Long> query = cb.createQuery(Long.class);
Root<UserAction> userAction = query.from(UserAction.class);
List<Predicate> predicates = new ArrayList<>();
if (table != null)
predicates.add(cb.equal(userAction.get("onTable"), table));
if (actionType != null)
predicates.add(cb.equal(userAction.get("actionType"), actionType));
if (itemId != null)
predicates.add(cb.equal(userAction.get("primaryKey"), itemId));
if (!predicates.isEmpty()) {
query.select(cb.count(userAction))
.where(cb.and(
predicates.toArray(new Predicate[predicates.size()])));
} else {
query.select(cb.count(userAction));
}
return entityManager.createQuery(query).getSingleResult();
}
}

View File

@ -0,0 +1,40 @@
package tech.ailef.dbadmin.internal.service;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.ailef.dbadmin.external.dto.PaginatedResult;
import tech.ailef.dbadmin.external.dto.PaginationInfo;
import tech.ailef.dbadmin.internal.model.UserAction;
import tech.ailef.dbadmin.internal.repository.ActionRepository;
import tech.ailef.dbadmin.internal.repository.CustomActionRepositoryImpl;
@Service
public class UserActionService {
@Autowired
private ActionRepository repo;
@Autowired
private CustomActionRepositoryImpl customRepo;
@Transactional("internalTransactionManager")
public UserAction save(UserAction a) {
return repo.save(a);
}
public PaginatedResult<UserAction> findActions(String table, String actionType, String userId, PageRequest page) {
long count = customRepo.countActions(table, actionType, userId);
List<UserAction> actions = customRepo.findActions(table, actionType, userId, page);
int maxPage = (int)(Math.ceil ((double)count / page.getPageSize()));
return new PaginatedResult<>(
new PaginationInfo(page.getPageNumber() + 1, maxPage, page.getPageSize(), count, null, null),
actions
);
}
}

View File

@ -0,0 +1,12 @@
document.addEventListener("DOMContentLoaded", () => {
let form = document.getElementById('log-filter-form');
if (form == null) return;
let selects = form.querySelectorAll('select');
selects.forEach(select => {
select.addEventListener('change', function(e) {
form.submit();
});
});
});

View File

@ -10,6 +10,7 @@
<script type="text/javascript" src="/js/autocomplete.js"></script>
<script type="text/javascript" src="/js/autocomplete-multi.js"></script>
<script type="text/javascript" src="/js/filters.js"></script>
<script type="text/javascript" src="/js/logs.js"></script>
<script type="text/javascript" src="/js/create.js"></script>
<title th:text="${title != null ? title + ' | Spring Boot DB Admin Panel' : 'Spring Boot DB Admin Panel'}"></title>
<script th:inline="javascript">
@ -64,6 +65,19 @@
</div>
</a>
</li>
<li th:class="${#strings.equals(activePage, 'logs') ? 'active' : ''}">
<a th:href="|/${baseUrl}/logs|">
<div class="d-flex align-items-center">
<div class="menu-icon">
<i class="bi bi-file-text"></i>
</div>
<div class="menu-entry-text d-none d-md-block">
Logs
</div>
</div>
</a>
</li>
<!--
<li th:class="${#strings.equals(activePage, 'console') ? 'active' : ''}">
<a href="/live">
@ -123,14 +137,15 @@
</div>
</div>
</div>
<nav aria-label="Results pagination" th:fragment="pagination(page)">
<div class="d-flex justify-content-between">
<div th:if="${page != null && page.getPagination().getMaxPage() != 1}" class="d-flex">
<ul class="pagination me-3">
<li class="page-item" th:if="${page.getPagination().getCurrentPage() != 1}">
<a class="page-link"
th:href="@{|/${baseUrl}/model/${schema.getClassName()}${page.getPagination().getLink(page.getPagination.getCurrentPage() - 1)}|}"
th:href="@{|${requestUrl}${page.getPagination().getLink(page.getPagination.getCurrentPage() - 1)}|}"
aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
<span class="sr-only">Previous</span>
@ -139,7 +154,7 @@
<li class="page-item" th:each="p : ${page.getPagination().getBeforePages()}">
<a class="page-link"
th:href="@{|/${baseUrl}/model/${schema.getClassName()}${page.getPagination().getLink(p)}|}" th:text="${p}"></a>
th:href="@{|${requestUrl}${page.getPagination().getLink(p)}|}" th:text="${p}"></a>
</li>
<li class="page-item active">
@ -148,13 +163,13 @@
<li class="page-item" th:each="p : ${page.getPagination().getAfterPages()}">
<a class="page-link"
th:href="@{|/${baseUrl}/model/${schema.getClassName()}${page.getPagination().getLink(p)}|}"
th:href="@{|${requestUrl}${page.getPagination().getLink(p)}|}"
th:text="${p}"></a>
</li>
<li class="page-item">
<a class="page-link"
th:if="${!page.getPagination().isLastPage()}"
th:href="@{|/${baseUrl}/model/${schema.getClassName()}${page.getPagination().getLink(page.getPagination.getCurrentPage() + 1)}|}"
th:href="@{|${requestUrl}${page.getPagination().getLink(page.getPagination.getCurrentPage() + 1)}|}"
aria-label="Next">
<span class="sr-only">Next</span>
<span aria-hidden="true">&raquo;</span>
@ -162,15 +177,15 @@
</li>
</ul>
<div class="me-3">
<form method="GET" th:action="@{|/${baseUrl}/model/${schema.getClassName()}|}">
<form method="GET" th:action="@{|${requestUrl}|}">
<input type="hidden" th:value="${page.getPagination().getCurrentPage()}" th:name="page">
<input type="hidden" th:value="${query}" th:name="query">
<input type="hidden" name="pageSize">
<th:block th:each="p : ${queryParams.keySet()}">
<input th:each="v : ${queryParams.get(p)}"
th:name="${p}" th:value="${v}" type="hidden"
th:if="${p.startsWith('filter_')}">
</th:block>
<input th:each="v : ${queryParams.get(p)}"
th:name="${p}" th:value="${v}" type="hidden"
th:if="${p.startsWith('filter_')}">
</th:block>
<select class="form-select page-size">
<option disabled>Page size</option>
<option th:selected="${page.getPagination().getPageSize() == 50}">50</option>
@ -183,14 +198,14 @@
<div class="d-flex align-items-center" th:if="${page.getPagination().getMaxPage() > 1}">
<p class="m-0 p-0">
<i>Showing [[ ${page.getActualResults()} ]] of [[ ${page.getPagination().getMaxElement()} ]] results</i>
<i>Showing [[ ${page.getNumberOfResults()} ]] of [[ ${page.getPagination().getMaxElement()} ]] results</i>
</p>
</div>
</div>
<div class="d-flex align-items-center" th:if="${page.getPagination().getMaxPage() == 1}">
<div class="me-3">
<form method="GET" th:action="@{|/${baseUrl}/model/${schema.getClassName()}|}">
<form method="GET" th:action="@{|${requestUrl}|}">
<input type="hidden" th:value="${page.getPagination().getCurrentPage()}" th:name="page">
<input type="hidden" th:value="${query}" th:name="query">
<input type="hidden" name="pageSize">
@ -204,7 +219,7 @@
</form>
</div>
<p class="m-0 p-0">
<i>Showing [[ ${page.getActualResults()} ]] of [[ ${page.getPagination().getMaxElement()} ]] results</i>
<i>Showing [[ ${page.getNumberOfResults()} ]] of [[ ${page.getPagination().getMaxElement()} ]] results</i>
</p>
</div>
@ -213,4 +228,5 @@
</div>
</div>
</nav>
</html>

View File

@ -14,31 +14,81 @@
</h1>
<div class="row mt-4">
<div class="col">
<div class="w-100 d-flex inner-navigation">
<a href="#" class="active">
<div class="ui-tab ps-5 pe-5 p-3">
<i class="bi bi-database pe-2"></i> APPEARANCE
</div>
</a>
<a href="#">
<div class="ui-tab ps-5 pe-5 p-3">
<i class="bi bi-table pe-2"></i> DATA
</div>
</a>
<div class="inner-navigation-border flex-grow-1">
</div>
</div>
<div class="box with-navigation">
<div class="row" th:each="entry : ${logs}">
<div class="col-3" th:text="${entry.getCreatedAt()}">
</div>
<div class="col-3" th:text="${entry.getActionType()}">
</div>
<div class="col-3" th:text="${entry.getOnTable()}">
</div>
<div class="col-3" th:text="${entry.getPrimaryKey()}">
</div>
<div class="box">
<h3 class="fw-bold">Logs</h3>
<div class="w-75">
<form th:action="|/${baseUrl}/logs|" class="mt-3" id="log-filter-form" method="GET">
<div class="input-group">
<span class="input-group-text">Action type</span>
<select name="actionType" class="form-select">
<option>Any</option>
<option value="CREATE"
th:selected="${searchRequest.getActionType() != null
&& searchRequest.getActionType().equalsIgnoreCase('CREATE') }">Create</option>
<option value="EDIT"
th:selected="${searchRequest.getActionType() != null
&& searchRequest.getActionType().equalsIgnoreCase('EDIT') }">Edit</option>
<option value="DELETE"
th:selected="${searchRequest.getActionType() != null
&& searchRequest.getActionType().equalsIgnoreCase('DELETE') }">Delete</option>
</select>
<span class="input-group-text ms-3">Table</span>
<select name="table" class="form-select">
<option value="Any">Any</option>
<option th:each="schema : ${schemas}"
th:value="${schema.getTableName()}"
th:text="${schema.getJavaClass().getSimpleName()}"
th:selected="${schema.getTableName().equals(searchRequest.getTable())}">
</option>
</select>
<span class="input-group-text ms-3">Item ID</span>
<input type="text" class="form-control" name="itemId"
th:value="${searchRequest.getItemId()}">
</div>
</form>
</div>
<div class="separator mt-3 mb-3"></div>
<div class="mt-3" th:if="${page.isEmpty()}">
<div class="alert alert-warning">There are no results for your filtering criteria</div>
</div>
<div class="table-responsive mt-3" th:if="${!page.isEmpty()}">
<nav th:replace="~{fragments/resources :: pagination(page=${page})}"></nav>
<table class="table table-striped mt-3">
<tr class="table-data-row">
<th>Action type</th>
<th>Table</th>
<th>Item ID</th>
<th>Time</th>
<th></th>
</tr>
<tr th:each="entry : ${page.getResults()}" class="table-data-row align-middle">
<td th:text="${entry.getActionType()}">
</td>
<td th:text="${entry.getOnTable()}">
</td>
<td th:text="${entry.getPrimaryKey()}">
</td>
<td th:text="${entry.getFormattedDate()}">
</td>
<td>
<!--
<a href="#" class="ui-btn btn btn-primary">
Diff <i class="text-white bi bi-search"></i>
</a>
-->
</td>
</tr>
</table>
<nav th:replace="~{fragments/resources :: pagination(page=${page})}"></nav>
</div>
</div>
</div>
</div>