feat(pagination): paginar listagem de servidores

- adiciona DTO de página e paginação no endpoint GET /api/servers
- aplica busca paginada no service/repositório com limites seguros
- atualiza dashboard e tabela React com controles e requisições paginadas
master
Artur Oliveira 2025-12-16 18:16:22 -03:00
parent d4d65ad0f9
commit 7460577423
7 changed files with 263 additions and 68 deletions

View File

@ -3,6 +3,7 @@ package com.hitcommunications.servermanager.controllers;
import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse; import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse;
import com.hitcommunications.servermanager.model.dtos.NewServerDTO; import com.hitcommunications.servermanager.model.dtos.NewServerDTO;
import com.hitcommunications.servermanager.model.dtos.ServerDTO; import com.hitcommunications.servermanager.model.dtos.ServerDTO;
import com.hitcommunications.servermanager.model.dtos.PagedResponse;
import com.hitcommunications.servermanager.services.ServersService; import com.hitcommunications.servermanager.services.ServersService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
@ -100,13 +101,15 @@ public class ServersController {
@GetMapping @GetMapping
@Operation(summary = "Pesquisa servidores com filtros combinados.") @Operation(summary = "Pesquisa servidores com filtros combinados.")
@ApiResponse(responseCode = "200", description = "Resultados retornados.") @ApiResponse(responseCode = "200", description = "Resultados retornados.")
public ResponseEntity<List<ServerDTO>> getAll( public ResponseEntity<PagedResponse<ServerDTO>> getAll(
@RequestParam(value = "query", required = false) String query, @RequestParam(value = "query", required = false) String query,
@RequestParam(value = "type", required = false) String type, @RequestParam(value = "type", required = false) String type,
@RequestParam(value = "application", required = false) String application, @RequestParam(value = "application", required = false) String application,
@RequestParam(value = "dbType", required = false) String dbType @RequestParam(value = "dbType", required = false) String dbType,
@RequestParam(value = "page", required = false, defaultValue = "0") Integer page,
@RequestParam(value = "size", required = false, defaultValue = "10") Integer size
) { ) {
return ResponseEntity.ok().body(serversService.search(query, type, application, dbType)); return ResponseEntity.ok().body(serversService.search(query, type, application, dbType, page, size));
} }
@PutMapping("/{id}") @PutMapping("/{id}")

View File

@ -0,0 +1,12 @@
package com.hitcommunications.servermanager.model.dtos;
import java.util.List;
public record PagedResponse<T>(
List<T> content,
long totalItems,
int totalPages,
int page,
int size
) {
}

View File

@ -1,13 +1,14 @@
package com.hitcommunications.servermanager.repositories; package com.hitcommunications.servermanager.repositories;
import com.hitcommunications.servermanager.model.Servers;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
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.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import com.hitcommunications.servermanager.model.Servers;
public interface ServersRepository extends JpaRepository<Servers, String> { public interface ServersRepository extends JpaRepository<Servers, String> {
Optional<Servers> findByName(String name); Optional<Servers> findByName(String name);
List<Servers> findByType(String type); List<Servers> findByType(String type);
@ -15,25 +16,44 @@ public interface ServersRepository extends JpaRepository<Servers, String> {
Optional<Servers> findByIpAndPort(String ip, Integer port); Optional<Servers> findByIpAndPort(String ip, Integer port);
Integer countAllByType(String type); Integer countAllByType(String type);
@Query(value = """ @Query(
select s.* from "server-manager".tab_servers s value = """
where select s.* from "server-manager".tab_servers s
(case when :query is null or length(:query) = 0 where
then true (case when :query is null or length(:query) = 0
else ( then true
lower(s.name) like lower(concat('%', cast(:query as text), '%')) else (
or lower(s.username) like lower(concat('%', cast(:query as text), '%')) lower(s.name) like lower(concat('%', cast(:query as text), '%'))
or lower(s.ip) like lower(concat('%', cast(:query as text), '%')) or lower(s.username) like lower(concat('%', cast(:query as text), '%'))
) or lower(s.ip) like lower(concat('%', cast(:query as text), '%'))
end) )
and (:type is null or s.type = :type) end)
and (:application is null or s.application = :application) and (:type is null or s.type = :type)
and (:dbType is null or s.db_type = :dbType) and (:application is null or s.application = :application)
""", nativeQuery = true) and (:dbType is null or s.db_type = :dbType)
List<Servers> search( """,
countQuery = """
select count(*) from "server-manager".tab_servers s
where
(case when :query is null or length(:query) = 0
then true
else (
lower(s.name) like lower(concat('%', cast(:query as text), '%'))
or lower(s.username) like lower(concat('%', cast(:query as text), '%'))
or lower(s.ip) like lower(concat('%', cast(:query as text), '%'))
)
end)
and (:type is null or s.type = :type)
and (:application is null or s.application = :application)
and (:dbType is null or s.db_type = :dbType)
""",
nativeQuery = true
)
Page<Servers> search(
@Param("query") String query, @Param("query") String query,
@Param("type") String type, @Param("type") String type,
@Param("application") String application, @Param("application") String application,
@Param("dbType") String dbType @Param("dbType") String dbType,
Pageable pageable
); );
} }

View File

@ -6,11 +6,15 @@ import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse;
import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse.FailedRow; import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse.FailedRow;
import com.hitcommunications.servermanager.model.dtos.NewServerDTO; import com.hitcommunications.servermanager.model.dtos.NewServerDTO;
import com.hitcommunications.servermanager.model.dtos.ServerDTO; import com.hitcommunications.servermanager.model.dtos.ServerDTO;
import com.hitcommunications.servermanager.model.dtos.PagedResponse;
import com.hitcommunications.servermanager.model.enums.TypeCategory; import com.hitcommunications.servermanager.model.enums.TypeCategory;
import com.hitcommunications.servermanager.repositories.ServersRepository; import com.hitcommunications.servermanager.repositories.ServersRepository;
import com.hitcommunications.servermanager.services.TypeOptionService; import com.hitcommunications.servermanager.services.TypeOptionService;
import com.hitcommunications.servermanager.utils.TypeNormalizer; import com.hitcommunications.servermanager.utils.TypeNormalizer;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@ -25,6 +29,9 @@ import java.util.LinkedHashMap;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class ServersService { public class ServersService {
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int MAX_PAGE_SIZE = 50;
private final ServersMapper mapper; private final ServersMapper mapper;
private final ServersRepository repo; private final ServersRepository repo;
private final TypeOptionService typeOptionService; private final TypeOptionService typeOptionService;
@ -176,16 +183,29 @@ public class ServersService {
.toList(); .toList();
} }
public List<ServerDTO> search(String query, String type, String application, String dbType) { public PagedResponse<ServerDTO> search(String query, String type, String application, String dbType, Integer page, Integer size) {
String normalizedQuery = (query == null || query.isBlank()) ? null : query.trim(); String normalizedQuery = (query == null || query.isBlank()) ? null : query.trim();
String typeFilter = normalizeFilter(type, TypeCategory.SERVER_TYPE); String typeFilter = normalizeFilter(type, TypeCategory.SERVER_TYPE);
String applicationFilter = normalizeFilter(application, TypeCategory.APPLICATION); String applicationFilter = normalizeFilter(application, TypeCategory.APPLICATION);
String dbTypeFilter = normalizeFilter(dbType, TypeCategory.DATABASE); String dbTypeFilter = normalizeFilter(dbType, TypeCategory.DATABASE);
int safePage = page != null && page >= 0 ? page : 0;
int requestedSize = (size != null && size > 0) ? size : DEFAULT_PAGE_SIZE;
int safeSize = Math.min(requestedSize, MAX_PAGE_SIZE);
Pageable pageable = PageRequest.of(safePage, safeSize);
return repo.search(normalizedQuery, typeFilter, applicationFilter, dbTypeFilter) Page<Servers> result = repo.search(normalizedQuery, typeFilter, applicationFilter, dbTypeFilter, pageable);
List<ServerDTO> content = result.getContent()
.stream() .stream()
.map(mapper::toDTO) .map(mapper::toDTO)
.toList(); .toList();
return new PagedResponse<>(
content,
result.getTotalElements(),
result.getTotalPages(),
result.getNumber(),
result.getSize()
);
} }
public ServerDTO update(String id, NewServerDTO updateDTO) { public ServerDTO update(String id, NewServerDTO updateDTO) {

View File

@ -4,9 +4,31 @@ interface Props {
servers: Server[]; servers: Server[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
page: number;
pageSize: number;
totalPages: number;
totalItems: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
} }
export const ServersTable = ({ servers, loading, error }: Props) => { const PAGE_SIZE_OPTIONS = [5, 10, 20, 50];
export const ServersTable = ({
servers,
loading,
error,
page,
pageSize,
totalPages,
totalItems,
onPageChange,
onPageSizeChange,
}: Props) => {
const showingFrom = totalItems === 0 ? 0 : page * pageSize + 1;
const showingTo = totalItems === 0 ? 0 : Math.min((page + 1) * pageSize, totalItems);
const hasResults = servers.length > 0;
return ( return (
<div className={Styles.card}> <div className={Styles.card}>
{loading && <div className={Styles.status}>Carregando servidores...</div>} {loading && <div className={Styles.status}>Carregando servidores...</div>}
@ -14,42 +36,86 @@ export const ServersTable = ({ servers, loading, error }: Props) => {
{!loading && !error && servers.length === 0 && ( {!loading && !error && servers.length === 0 && (
<div className={Styles.status}>Nenhum servidor encontrado.</div> <div className={Styles.status}>Nenhum servidor encontrado.</div>
)} )}
{!loading && !error && servers.length > 0 && ( {!loading && !error && hasResults && (
<div className={Styles.tableWrapper}> <>
<table className={Styles.table}> <div className={Styles.tableWrapper}>
<thead className={Styles.tableHead}> <table className={Styles.table}>
<tr className="text-left"> <thead className={Styles.tableHead}>
<th className={Styles.tableHeadCell}>Nome</th> <tr className="text-left">
<th className={Styles.tableHeadCell}>IP</th> <th className={Styles.tableHeadCell}>Nome</th>
<th className={Styles.tableHeadCell}>Porta</th> <th className={Styles.tableHeadCell}>IP</th>
<th className={Styles.tableHeadCell}>Usuário</th> <th className={Styles.tableHeadCell}>Porta</th>
<th className={Styles.tableHeadCell}>Senha</th> <th className={Styles.tableHeadCell}>Usuário</th>
<th className={Styles.tableHeadCell}>Tipo</th> <th className={Styles.tableHeadCell}>Senha</th>
<th className={Styles.tableHeadCell}>Aplicação</th> <th className={Styles.tableHeadCell}>Tipo</th>
<th className={Styles.tableHeadCell}>Banco</th> <th className={Styles.tableHeadCell}>Aplicação</th>
</tr> <th className={Styles.tableHeadCell}>Banco</th>
</thead>
<tbody className={Styles.tableBody}>
{servers.map((server) => (
<tr
key={server.id}
className="hover:bg-gray-50 transition-colors even:bg-gray-50/50"
>
<td className={Styles.rowCell}>{server.name}</td>
<td className={Styles.rowCell}>{server.ip}</td>
<td className={Styles.rowCell}>{server.port}</td>
<td className={Styles.rowCell}>{server.user}</td>
<td className={Styles.rowCell}>
<code className="text-xs bg-gray-100 px-2 py-1 rounded">{server.password}</code>
</td>
<td className={`${Styles.rowCell} capitalize`}>{server.type.toLowerCase()}</td>
<td className={`${Styles.rowCell} capitalize`}>{server.application.toLowerCase()}</td>
<td className={`${Styles.rowCell} capitalize`}>{server.dbType.toLowerCase()}</td>
</tr> </tr>
))} </thead>
</tbody> <tbody className={Styles.tableBody}>
</table> {servers.map((server) => (
</div> <tr
key={server.id}
className="hover:bg-gray-50 transition-colors even:bg-gray-50/50"
>
<td className={Styles.rowCell}>{server.name}</td>
<td className={Styles.rowCell}>{server.ip}</td>
<td className={Styles.rowCell}>{server.port}</td>
<td className={Styles.rowCell}>{server.user}</td>
<td className={Styles.rowCell}>
<code className="text-xs bg-gray-100 px-2 py-1 rounded">{server.password}</code>
</td>
<td className={`${Styles.rowCell} capitalize`}>{server.type.toLowerCase()}</td>
<td className={`${Styles.rowCell} capitalize`}>{server.application.toLowerCase()}</td>
<td className={`${Styles.rowCell} capitalize`}>{server.dbType.toLowerCase()}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className={Styles.pagination}>
<div className={Styles.pageInfo}>
Mostrando {showingFrom} - {showingTo} de {totalItems}
</div>
<div className={Styles.paginationControls}>
<label className={Styles.pageSizeLabel}>
Linhas por página
<select
className={Styles.pageSizeSelect}
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
>
{PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<div className={Styles.pageButtons}>
<button
type="button"
className={Styles.pageButton}
onClick={() => onPageChange(page - 1)}
disabled={page <= 0}
>
Anterior
</button>
<span className={Styles.pageIndicator}>
Página {page + 1} de {Math.max(totalPages, 1)}
</span>
<button
type="button"
className={Styles.pageButton}
onClick={() => onPageChange(page + 1)}
disabled={totalPages === 0 || page >= totalPages - 1}
>
Próxima
</button>
</div>
</div>
</div>
</>
)} )}
</div> </div>
); );
@ -65,4 +131,12 @@ const Styles = {
tableHeadCell: "px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-text-secondary", tableHeadCell: "px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-text-secondary",
tableBody: "bg-white divide-y divide-cardBorder", tableBody: "bg-white divide-y divide-cardBorder",
rowCell: "px-4 py-3 text-sm text-text", rowCell: "px-4 py-3 text-sm text-text",
pagination: "flex flex-col gap-2 border-t border-cardBorder bg-white px-4 py-3 sm:flex-row sm:items-center sm:justify-between",
pageInfo: "text-sm text-text-secondary",
paginationControls: "flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4",
pageSizeLabel: "text-sm text-text flex items-center gap-2",
pageSizeSelect: "rounded-md border border-cardBorder bg-white px-2 py-1 text-sm text-text outline-none focus:border-accent focus:ring-1 focus:ring-accent",
pageButtons: "flex items-center gap-3",
pageButton: "rounded-md border border-cardBorder px-3 py-1.5 text-sm font-medium text-text hover:bg-bg disabled:opacity-50 disabled:hover:bg-transparent",
pageIndicator: "text-sm text-text-secondary",
}; };

View File

@ -9,6 +9,7 @@ import { ServersFilterBar } from "../components/ServersFilterBar";
import type { Server } from "../types/Server"; import type { Server } from "../types/Server";
import type { User } from "../types/User"; import type { User } from "../types/User";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import type { PaginatedResponse } from "../types/Pagination";
export const Dashboard = () => { export const Dashboard = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -21,6 +22,10 @@ export const Dashboard = () => {
const [typeFilter, setTypeFilter] = useState<string>("ALL"); const [typeFilter, setTypeFilter] = useState<string>("ALL");
const [applicationFilter, setApplicationFilter] = useState<string>("ALL"); const [applicationFilter, setApplicationFilter] = useState<string>("ALL");
const [dbFilter, setDbFilter] = useState<string>("ALL"); const [dbFilter, setDbFilter] = useState<string>("ALL");
const [page, setPage] = useState<number>(0);
const [pageSize, setPageSize] = useState<number>(10);
const [totalPages, setTotalPages] = useState<number>(0);
const [totalItems, setTotalItems] = useState<number>(0);
const [serverTypeOptions, setServerTypeOptions] = useState<string[]>([]); const [serverTypeOptions, setServerTypeOptions] = useState<string[]>([]);
const [applicationOptions, setApplicationOptions] = useState<string[]>([]); const [applicationOptions, setApplicationOptions] = useState<string[]>([]);
const [databaseOptions, setDatabaseOptions] = useState<string[]>([]); const [databaseOptions, setDatabaseOptions] = useState<string[]>([]);
@ -42,9 +47,19 @@ export const Dashboard = () => {
if (dbFilter !== "ALL" && dbFilter.trim().length > 0) { if (dbFilter !== "ALL" && dbFilter.trim().length > 0) {
params.set("dbType", dbFilter); params.set("dbType", dbFilter);
} }
params.set("page", String(page));
params.set("size", String(pageSize));
const endpoint = params.toString() ? `/api/servers?${params.toString()}` : "/api/servers"; const endpoint = params.toString() ? `/api/servers?${params.toString()}` : "/api/servers";
const { data } = await api.get<Server[]>(endpoint); const { data } = await api.get<PaginatedResponse<Server>>(endpoint);
setServers(data); setServers(data.content ?? []);
setTotalPages(data.totalPages ?? 0);
setTotalItems(data.totalItems ?? 0);
if (typeof data.page === "number") {
setPage(data.page);
}
if (typeof data.size === "number") {
setPageSize(data.size);
}
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
const message = err?.response?.data?.message || "Falha ao carregar servidores."; const message = err?.response?.data?.message || "Falha ao carregar servidores.";
@ -52,7 +67,7 @@ export const Dashboard = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [searchTerm, typeFilter, applicationFilter, dbFilter]); }, [searchTerm, typeFilter, applicationFilter, dbFilter, page, pageSize]);
const fetchCurrentUser = useCallback(async () => { const fetchCurrentUser = useCallback(async () => {
try { try {
@ -101,6 +116,40 @@ export const Dashboard = () => {
fetchServers(); fetchServers();
}, [fetchServers]); }, [fetchServers]);
const handleSearchChange = (value: string) => {
setPage(0);
setSearchTerm(value);
};
const handleTypeChange = (value: string) => {
setPage(0);
setTypeFilter(value);
};
const handleApplicationChange = (value: string) => {
setPage(0);
setApplicationFilter(value);
};
const handleDbTypeChange = (value: string) => {
setPage(0);
setDbFilter(value);
};
const handlePageChange = (nextPage: number) => {
const maxPage = totalPages > 0 ? totalPages - 1 : 0;
const safePage = Math.max(0, Math.min(nextPage, maxPage));
setPage(safePage);
};
const handlePageSizeChange = (nextSize: number) => {
if (nextSize <= 0) {
return;
}
setPage(0);
setPageSize(nextSize);
};
useEffect(() => { useEffect(() => {
fetchTypeOptions(); fetchTypeOptions();
}, [fetchTypeOptions]); }, [fetchTypeOptions]);
@ -123,15 +172,25 @@ export const Dashboard = () => {
type={typeFilter} type={typeFilter}
application={applicationFilter} application={applicationFilter}
dbType={dbFilter} dbType={dbFilter}
onSearchChange={setSearchTerm} onSearchChange={handleSearchChange}
onTypeChange={setTypeFilter} onTypeChange={handleTypeChange}
onApplicationChange={setApplicationFilter} onApplicationChange={handleApplicationChange}
onDbTypeChange={setDbFilter} onDbTypeChange={handleDbTypeChange}
serverTypeOptions={serverTypeOptions} serverTypeOptions={serverTypeOptions}
applicationOptions={applicationOptions} applicationOptions={applicationOptions}
databaseOptions={databaseOptions} databaseOptions={databaseOptions}
/> />
<ServersTable servers={servers} loading={loading} error={error} /> <ServersTable
servers={servers}
loading={loading}
error={error}
page={page}
pageSize={pageSize}
totalPages={totalPages}
totalItems={totalItems}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>
</div> </div>
</Layout> </Layout>
); );

View File

@ -0,0 +1,7 @@
export interface PaginatedResponse<T> {
content: T[];
totalItems: number;
totalPages: number;
page: number;
size: number;
}