From 49a9d58421f6b493b1ed225cd4e1243e625dbfd8 Mon Sep 17 00:00:00 2001 From: Francesco Date: Sat, 23 Sep 2023 16:17:57 +0200 Subject: [PATCH] WIP --- .../tech/ailef/dbadmin/external/DbAdmin.java | 12 +++ .../external/DbAdminAutoConfiguration.java | 4 + .../controller/DefaultDbAdminController.java | 38 +++++-- .../external/controller/GlobalController.java | 7 +- .../external/dbmapping/DbAdminRepository.java | 4 +- .../external/dto/ListModelRequest.java | 38 ------- .../external/dto/LogsSearchRequest.java | 98 +++++++++++++++++ .../dbadmin/external/dto/PaginatedResult.java | 14 ++- .../dbadmin/external/dto/PaginationInfo.java | 1 + .../ailef/dbadmin/external/misc/Utils.java | 2 + .../InternalDbAdminConfiguration.java | 12 +++ .../dbadmin/internal/model/UserAction.java | 7 +- .../internal/repository/ActionRepository.java | 8 +- .../repository/CustomActionRepository.java | 14 +++ .../CustomActionRepositoryImpl.java | 82 ++++++++++++++ .../internal/service/UserActionService.java | 40 +++++++ src/main/resources/static/js/logs.js | 12 +++ .../templates/fragments/resources.html | 44 +++++--- src/main/resources/templates/logs.html | 100 +++++++++++++----- 19 files changed, 441 insertions(+), 96 deletions(-) delete mode 100644 src/main/java/tech/ailef/dbadmin/external/dto/ListModelRequest.java create mode 100644 src/main/java/tech/ailef/dbadmin/external/dto/LogsSearchRequest.java create mode 100644 src/main/java/tech/ailef/dbadmin/internal/InternalDbAdminConfiguration.java create mode 100644 src/main/java/tech/ailef/dbadmin/internal/repository/CustomActionRepository.java create mode 100644 src/main/java/tech/ailef/dbadmin/internal/repository/CustomActionRepositoryImpl.java create mode 100644 src/main/java/tech/ailef/dbadmin/internal/service/UserActionService.java create mode 100644 src/main/resources/static/js/logs.js diff --git a/src/main/java/tech/ailef/dbadmin/external/DbAdmin.java b/src/main/java/tech/ailef/dbadmin/external/DbAdmin.java index 199b903..2de4e69 100644 --- a/src/main/java/tech/ailef/dbadmin/external/DbAdmin.java +++ b/src/main/java/tech/ailef/dbadmin/external/DbAdmin.java @@ -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 diff --git a/src/main/java/tech/ailef/dbadmin/external/DbAdminAutoConfiguration.java b/src/main/java/tech/ailef/dbadmin/external/DbAdminAutoConfiguration.java index d29b180..ed35dcb 100644 --- a/src/main/java/tech/ailef/dbadmin/external/DbAdminAutoConfiguration.java +++ b/src/main/java/tech/ailef/dbadmin/external/DbAdminAutoConfiguration.java @@ -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; diff --git a/src/main/java/tech/ailef/dbadmin/external/controller/DefaultDbAdminController.java b/src/main/java/tech/ailef/dbadmin/external/controller/DefaultDbAdminController.java index df9e74e..5925d00 100644 --- a/src/main/java/tech/ailef/dbadmin/external/controller/DefaultDbAdminController.java +++ b/src/main/java/tech/ailef/dbadmin/external/controller/DefaultDbAdminController.java @@ -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); } } diff --git a/src/main/java/tech/ailef/dbadmin/external/controller/GlobalController.java b/src/main/java/tech/ailef/dbadmin/external/controller/GlobalController.java index 7d8640c..1b45540 100644 --- a/src/main/java/tech/ailef/dbadmin/external/controller/GlobalController.java +++ b/src/main/java/tech/ailef/dbadmin/external/controller/GlobalController.java @@ -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(); + } } \ No newline at end of file diff --git a/src/main/java/tech/ailef/dbadmin/external/dbmapping/DbAdminRepository.java b/src/main/java/tech/ailef/dbadmin/external/dbmapping/DbAdminRepository.java index 8126c77..aace47c 100644 --- a/src/main/java/tech/ailef/dbadmin/external/dbmapping/DbAdminRepository.java +++ b/src/main/java/tech/ailef/dbadmin/external/dbmapping/DbAdminRepository.java @@ -117,7 +117,7 @@ public class DbAdminRepository { } - return new PaginatedResult( + return new PaginatedResult( 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( new PaginationInfo(page, maxPage, pageSize, maxElement, query, queryFilters), jpaRepository.search(query, page, pageSize, sortKey, sortOrder, queryFilters).stream() .map(o -> new DbObject(o, schema)) diff --git a/src/main/java/tech/ailef/dbadmin/external/dto/ListModelRequest.java b/src/main/java/tech/ailef/dbadmin/external/dto/ListModelRequest.java deleted file mode 100644 index 4fb3463..0000000 --- a/src/main/java/tech/ailef/dbadmin/external/dto/ListModelRequest.java +++ /dev/null @@ -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 queryFilters; -// -// private PaginationInfo paginationInfo; -// -// public ListModelRequest(String className, String query, Integer page, Integer pageSize, String sortKey, -// String sortOrder, Set 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 otherParams, -//} diff --git a/src/main/java/tech/ailef/dbadmin/external/dto/LogsSearchRequest.java b/src/main/java/tech/ailef/dbadmin/external/dto/LogsSearchRequest.java new file mode 100644 index 0000000..e766731 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/external/dto/LogsSearchRequest.java @@ -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()); + } + } + +} diff --git a/src/main/java/tech/ailef/dbadmin/external/dto/PaginatedResult.java b/src/main/java/tech/ailef/dbadmin/external/dto/PaginatedResult.java index 8f2def9..da21851 100644 --- a/src/main/java/tech/ailef/dbadmin/external/dto/PaginatedResult.java +++ b/src/main/java/tech/ailef/dbadmin/external/dto/PaginatedResult.java @@ -4,12 +4,12 @@ import java.util.List; import tech.ailef.dbadmin.external.dbmapping.DbObject; -public class PaginatedResult { +public class PaginatedResult { private PaginationInfo pagination; - private List results; + private List results; - public PaginatedResult(PaginationInfo pagination, List page) { + public PaginatedResult(PaginationInfo pagination, List page) { this.pagination = pagination; this.results = page; } @@ -18,11 +18,15 @@ public class PaginatedResult { return pagination; } - public List getResults() { + public List getResults() { return results; } - public int getActualResults() { + public boolean isEmpty() { + return results.isEmpty(); + } + + public int getNumberOfResults() { return getResults().size(); } diff --git a/src/main/java/tech/ailef/dbadmin/external/dto/PaginationInfo.java b/src/main/java/tech/ailef/dbadmin/external/dto/PaginationInfo.java index 4d0dcb8..7e87237 100644 --- a/src/main/java/tech/ailef/dbadmin/external/dto/PaginationInfo.java +++ b/src/main/java/tech/ailef/dbadmin/external/dto/PaginationInfo.java @@ -35,6 +35,7 @@ public class PaginationInfo { */ private int pageSize; + // TODO: Check if used private long maxElement; private Set queryFilters; diff --git a/src/main/java/tech/ailef/dbadmin/external/misc/Utils.java b/src/main/java/tech/ailef/dbadmin/external/misc/Utils.java index 93a2357..6000746 100644 --- a/src/main/java/tech/ailef/dbadmin/external/misc/Utils.java +++ b/src/main/java/tech/ailef/dbadmin/external/misc/Utils.java @@ -24,6 +24,8 @@ public interface Utils { public static MultiValueMap computeParams(Set filters) { MultiValueMap r = new LinkedMultiValueMap<>(); + if (filters == null) + return r; r.put("filter_field", new ArrayList<>()); r.put("filter_op", new ArrayList<>()); diff --git a/src/main/java/tech/ailef/dbadmin/internal/InternalDbAdminConfiguration.java b/src/main/java/tech/ailef/dbadmin/internal/InternalDbAdminConfiguration.java new file mode 100644 index 0000000..c94fb08 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/internal/InternalDbAdminConfiguration.java @@ -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 { + +} diff --git a/src/main/java/tech/ailef/dbadmin/internal/model/UserAction.java b/src/main/java/tech/ailef/dbadmin/internal/model/UserAction.java index ff93c7f..35ec6ae 100644 --- a/src/main/java/tech/ailef/dbadmin/internal/model/UserAction.java +++ b/src/main/java/tech/ailef/dbadmin/internal/model/UserAction.java @@ -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; @@ -89,5 +91,8 @@ public class UserAction { public void setActionType(String actionType) { this.actionType = actionType; } - + + public String getFormattedDate() { + return new DateTimeFormatterFactory("YYYY-MM-dd HH:mm:ss").createDateTimeFormatter().format(createdAt); + } } diff --git a/src/main/java/tech/ailef/dbadmin/internal/repository/ActionRepository.java b/src/main/java/tech/ailef/dbadmin/internal/repository/ActionRepository.java index 46df0be..eb64f46 100644 --- a/src/main/java/tech/ailef/dbadmin/internal/repository/ActionRepository.java +++ b/src/main/java/tech/ailef/dbadmin/internal/repository/ActionRepository.java @@ -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 { - +public interface ActionRepository extends JpaRepository, CustomActionRepository { + public List findAllByOnTableAndActionTypeAndPrimaryKey(String table, String actionType, String primaryKey, PageRequest pageRequest); + } diff --git a/src/main/java/tech/ailef/dbadmin/internal/repository/CustomActionRepository.java b/src/main/java/tech/ailef/dbadmin/internal/repository/CustomActionRepository.java new file mode 100644 index 0000000..50db002 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/internal/repository/CustomActionRepository.java @@ -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 findActions(String table, String actionType, String itemId, PageRequest pageRequest); + + public long countActions(String table, String actionType, String itemId); + +} diff --git a/src/main/java/tech/ailef/dbadmin/internal/repository/CustomActionRepositoryImpl.java b/src/main/java/tech/ailef/dbadmin/internal/repository/CustomActionRepositoryImpl.java new file mode 100644 index 0000000..176dce5 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/internal/repository/CustomActionRepositoryImpl.java @@ -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 findActions(String table, String actionType, String itemId, PageRequest page) { + + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + CriteriaQuery query = cb.createQuery(UserAction.class); + Root userAction = query.from(UserAction.class); + + List 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(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 query = cb.createQuery(Long.class); + Root userAction = query.from(UserAction.class); + + List 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(); + } + +} \ No newline at end of file diff --git a/src/main/java/tech/ailef/dbadmin/internal/service/UserActionService.java b/src/main/java/tech/ailef/dbadmin/internal/service/UserActionService.java new file mode 100644 index 0000000..2721f8a --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/internal/service/UserActionService.java @@ -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 findActions(String table, String actionType, String userId, PageRequest page) { + long count = customRepo.countActions(table, actionType, userId); + List 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 + ); + } + +} diff --git a/src/main/resources/static/js/logs.js b/src/main/resources/static/js/logs.js new file mode 100644 index 0000000..9aadf4d --- /dev/null +++ b/src/main/resources/static/js/logs.js @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/src/main/resources/templates/fragments/resources.html b/src/main/resources/templates/fragments/resources.html index 22bd05c..a7dc215 100644 --- a/src/main/resources/templates/fragments/resources.html +++ b/src/main/resources/templates/fragments/resources.html @@ -10,6 +10,7 @@ +