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 paginadasmaster
parent
d4d65ad0f9
commit
7460577423
|
|
@ -3,6 +3,7 @@ package com.hitcommunications.servermanager.controllers;
|
|||
import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse;
|
||||
import com.hitcommunications.servermanager.model.dtos.NewServerDTO;
|
||||
import com.hitcommunications.servermanager.model.dtos.ServerDTO;
|
||||
import com.hitcommunications.servermanager.model.dtos.PagedResponse;
|
||||
import com.hitcommunications.servermanager.services.ServersService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
|
|
@ -100,13 +101,15 @@ public class ServersController {
|
|||
@GetMapping
|
||||
@Operation(summary = "Pesquisa servidores com filtros combinados.")
|
||||
@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 = "type", required = false) String type,
|
||||
@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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
package com.hitcommunications.servermanager.repositories;
|
||||
|
||||
import com.hitcommunications.servermanager.model.Servers;
|
||||
import java.util.List;
|
||||
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.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import com.hitcommunications.servermanager.model.Servers;
|
||||
public interface ServersRepository extends JpaRepository<Servers, String> {
|
||||
Optional<Servers> findByName(String name);
|
||||
List<Servers> findByType(String type);
|
||||
|
|
@ -15,25 +16,44 @@ public interface ServersRepository extends JpaRepository<Servers, String> {
|
|||
Optional<Servers> findByIpAndPort(String ip, Integer port);
|
||||
Integer countAllByType(String type);
|
||||
|
||||
@Query(value = """
|
||||
select s.* 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)
|
||||
List<Servers> search(
|
||||
@Query(
|
||||
value = """
|
||||
select s.* 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)
|
||||
""",
|
||||
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("type") String type,
|
||||
@Param("application") String application,
|
||||
@Param("dbType") String dbType
|
||||
@Param("dbType") String dbType,
|
||||
Pageable pageable
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.NewServerDTO;
|
||||
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.repositories.ServersRepository;
|
||||
import com.hitcommunications.servermanager.services.TypeOptionService;
|
||||
import com.hitcommunications.servermanager.utils.TypeNormalizer;
|
||||
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.web.multipart.MultipartFile;
|
||||
|
||||
|
|
@ -25,6 +29,9 @@ import java.util.LinkedHashMap;
|
|||
@Service
|
||||
@RequiredArgsConstructor
|
||||
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 ServersRepository repo;
|
||||
private final TypeOptionService typeOptionService;
|
||||
|
|
@ -176,16 +183,29 @@ public class ServersService {
|
|||
.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 typeFilter = normalizeFilter(type, TypeCategory.SERVER_TYPE);
|
||||
String applicationFilter = normalizeFilter(application, TypeCategory.APPLICATION);
|
||||
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()
|
||||
.map(mapper::toDTO)
|
||||
.toList();
|
||||
|
||||
return new PagedResponse<>(
|
||||
content,
|
||||
result.getTotalElements(),
|
||||
result.getTotalPages(),
|
||||
result.getNumber(),
|
||||
result.getSize()
|
||||
);
|
||||
}
|
||||
|
||||
public ServerDTO update(String id, NewServerDTO updateDTO) {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,31 @@ interface Props {
|
|||
servers: Server[];
|
||||
loading: boolean;
|
||||
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 (
|
||||
<div className={Styles.card}>
|
||||
{loading && <div className={Styles.status}>Carregando servidores...</div>}
|
||||
|
|
@ -14,42 +36,86 @@ export const ServersTable = ({ servers, loading, error }: Props) => {
|
|||
{!loading && !error && servers.length === 0 && (
|
||||
<div className={Styles.status}>Nenhum servidor encontrado.</div>
|
||||
)}
|
||||
{!loading && !error && servers.length > 0 && (
|
||||
<div className={Styles.tableWrapper}>
|
||||
<table className={Styles.table}>
|
||||
<thead className={Styles.tableHead}>
|
||||
<tr className="text-left">
|
||||
<th className={Styles.tableHeadCell}>Nome</th>
|
||||
<th className={Styles.tableHeadCell}>IP</th>
|
||||
<th className={Styles.tableHeadCell}>Porta</th>
|
||||
<th className={Styles.tableHeadCell}>Usuário</th>
|
||||
<th className={Styles.tableHeadCell}>Senha</th>
|
||||
<th className={Styles.tableHeadCell}>Tipo</th>
|
||||
<th className={Styles.tableHeadCell}>Aplicação</th>
|
||||
<th className={Styles.tableHeadCell}>Banco</th>
|
||||
</tr>
|
||||
</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>
|
||||
{!loading && !error && hasResults && (
|
||||
<>
|
||||
<div className={Styles.tableWrapper}>
|
||||
<table className={Styles.table}>
|
||||
<thead className={Styles.tableHead}>
|
||||
<tr className="text-left">
|
||||
<th className={Styles.tableHeadCell}>Nome</th>
|
||||
<th className={Styles.tableHeadCell}>IP</th>
|
||||
<th className={Styles.tableHeadCell}>Porta</th>
|
||||
<th className={Styles.tableHeadCell}>Usuário</th>
|
||||
<th className={Styles.tableHeadCell}>Senha</th>
|
||||
<th className={Styles.tableHeadCell}>Tipo</th>
|
||||
<th className={Styles.tableHeadCell}>Aplicação</th>
|
||||
<th className={Styles.tableHeadCell}>Banco</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
|
|
@ -65,4 +131,12 @@ const Styles = {
|
|||
tableHeadCell: "px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-text-secondary",
|
||||
tableBody: "bg-white divide-y divide-cardBorder",
|
||||
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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { ServersFilterBar } from "../components/ServersFilterBar";
|
|||
import type { Server } from "../types/Server";
|
||||
import type { User } from "../types/User";
|
||||
import toast from "react-hot-toast";
|
||||
import type { PaginatedResponse } from "../types/Pagination";
|
||||
|
||||
export const Dashboard = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -21,6 +22,10 @@ export const Dashboard = () => {
|
|||
const [typeFilter, setTypeFilter] = useState<string>("ALL");
|
||||
const [applicationFilter, setApplicationFilter] = 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 [applicationOptions, setApplicationOptions] = useState<string[]>([]);
|
||||
const [databaseOptions, setDatabaseOptions] = useState<string[]>([]);
|
||||
|
|
@ -42,9 +47,19 @@ export const Dashboard = () => {
|
|||
if (dbFilter !== "ALL" && dbFilter.trim().length > 0) {
|
||||
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 { data } = await api.get<Server[]>(endpoint);
|
||||
setServers(data);
|
||||
const { data } = await api.get<PaginatedResponse<Server>>(endpoint);
|
||||
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);
|
||||
} catch (err: any) {
|
||||
const message = err?.response?.data?.message || "Falha ao carregar servidores.";
|
||||
|
|
@ -52,7 +67,7 @@ export const Dashboard = () => {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchTerm, typeFilter, applicationFilter, dbFilter]);
|
||||
}, [searchTerm, typeFilter, applicationFilter, dbFilter, page, pageSize]);
|
||||
|
||||
const fetchCurrentUser = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -101,6 +116,40 @@ export const Dashboard = () => {
|
|||
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(() => {
|
||||
fetchTypeOptions();
|
||||
}, [fetchTypeOptions]);
|
||||
|
|
@ -123,15 +172,25 @@ export const Dashboard = () => {
|
|||
type={typeFilter}
|
||||
application={applicationFilter}
|
||||
dbType={dbFilter}
|
||||
onSearchChange={setSearchTerm}
|
||||
onTypeChange={setTypeFilter}
|
||||
onApplicationChange={setApplicationFilter}
|
||||
onDbTypeChange={setDbFilter}
|
||||
onSearchChange={handleSearchChange}
|
||||
onTypeChange={handleTypeChange}
|
||||
onApplicationChange={handleApplicationChange}
|
||||
onDbTypeChange={handleDbTypeChange}
|
||||
serverTypeOptions={serverTypeOptions}
|
||||
applicationOptions={applicationOptions}
|
||||
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>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
export interface PaginatedResponse<T> {
|
||||
content: T[];
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
Loading…
Reference in New Issue