feat(servers): adicionar importação em massa via CSV

- Expõe endpoint bulk que lê arquivos ;, cria servidores e retorna falhas
- Cria DTOs e logs de validação para informar erros por linha
- Implementa modal no header com upload CSV e download de template
master
Artur Oliveira 2025-12-16 14:10:03 -03:00
parent 61b3af4c53
commit b6ba3b8593
7 changed files with 345 additions and 4 deletions

View File

@ -1,5 +1,6 @@
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.enums.Applications;
@ -9,6 +10,7 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
@ -45,6 +47,11 @@ public class ServersController {
return ResponseEntity.ok().body(serversService.countAllByType());
}
@PostMapping("/bulk")
public ResponseEntity<BulkServerImportResponse> bulkCreate(@RequestParam("file") MultipartFile file) {
return ResponseEntity.ok().body(serversService.bulkCreate(file));
}
@GetMapping("/application/{application}")
public ResponseEntity<List<ServerDTO>> getByApplication(@PathVariable Applications application) {
return ResponseEntity.ok().body(serversService.getByApplication(application));
@ -66,4 +73,3 @@ public class ServersController {
return ResponseEntity.noContent().build();
}
}

View File

@ -0,0 +1,17 @@
package com.hitcommunications.servermanager.model.dtos;
import java.util.List;
public record BulkServerImportResponse(
int total,
int succeeded,
int failed,
List<FailedRow> failures
) {
public record FailedRow(
int line,
String error,
String raw
) {
}
}

View File

@ -2,17 +2,26 @@ package com.hitcommunications.servermanager.services;
import com.hitcommunications.servermanager.mappers.ServersMapper;
import com.hitcommunications.servermanager.model.Servers;
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.enums.Applications;
import com.hitcommunications.servermanager.model.enums.DatabaseType;
import com.hitcommunications.servermanager.model.enums.ServersType;
import com.hitcommunications.servermanager.repositories.ServersRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.ArrayList;
@Service
@RequiredArgsConstructor
@ -67,6 +76,77 @@ public class ServersService {
.toList();
}
public BulkServerImportResponse bulkCreate(MultipartFile file) {
List<FailedRow> failures = new ArrayList<>();
int succeeded = 0;
int processed = 0;
try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
String line;
int lineNumber = 0;
while ((line = reader.readLine()) != null) {
lineNumber++;
if (line.isBlank()) {
continue;
}
String[] columns = parseColumns(line);
if (isHeaderRow(columns)) {
continue;
}
processed++;
try {
NewServerDTO dto = toNewServerDTO(columns);
create(dto);
succeeded++;
} catch (Exception ex) {
failures.add(new FailedRow(lineNumber, ex.getMessage(), line));
}
}
} catch (IOException e) {
throw new RuntimeException("Erro ao ler arquivo CSV.", e);
}
int failed = failures.size();
return new BulkServerImportResponse(processed, succeeded, failed, failures);
}
private String[] parseColumns(String line) {
String[] rawColumns = line.split(";");
String[] columns = new String[rawColumns.length];
for (int i = 0; i < rawColumns.length; i++) {
columns[i] = rawColumns[i].trim();
}
return columns;
}
private NewServerDTO toNewServerDTO(String[] columns) {
if (columns.length < 8) {
throw new IllegalArgumentException("Linha incompleta. Esperado 8 colunas.");
}
String name = columns[0];
String ip = columns[1];
Integer port = Integer.parseInt(columns[2]);
String user = columns[3];
String password = columns[4];
ServersType type = ServersType.valueOf(columns[5].toUpperCase());
Applications application = Applications.valueOf(columns[6].toUpperCase());
DatabaseType dbType = DatabaseType.valueOf(columns[7].toUpperCase());
return new NewServerDTO(name, ip, port, user, password, type, application, dbType);
}
private boolean isHeaderRow(String[] columns) {
if (columns.length < 8) {
return false;
}
return "name".equalsIgnoreCase(columns[0]) &&
"ip".equalsIgnoreCase(columns[1]) &&
"port".equalsIgnoreCase(columns[2]);
}
public List<ServerDTO> getAll() {
return repo.findAll()
.stream()
@ -97,4 +177,3 @@ public class ServersService {
repo.deleteById(id);
}
}

View File

@ -5,11 +5,13 @@ import { HeaderActions } from "./header/HeaderActions";
import { HeaderBrand } from "./header/HeaderBrand";
import { ProfileModal } from "./header/ProfileModal";
import { ServerModal } from "./header/ServerModal";
import { BulkImportModal } from "./header/BulkImportModal";
import type { Applications, DatabaseType, ServersType } from "../types/enums";
import type { User } from "../types/User";
import type { ProfileFormState, ServerFormState } from "./header/types";
import type { BulkImportResult } from "../types/BulkImport";
type ModalType = "addServer" | "editProfile" | null;
type ModalType = "addServer" | "editProfile" | "bulkImport" | null;
interface HeaderProps {
currentUser: User | null;
@ -47,6 +49,10 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
const [profileForm, setProfileForm] = useState<ProfileFormState>(defaultProfileForm);
const [serverLoading, setServerLoading] = useState(false);
const [profileLoading, setProfileLoading] = useState(false);
const [bulkFile, setBulkFile] = useState<File | null>(null);
const [bulkLoading, setBulkLoading] = useState(false);
const [bulkResult, setBulkResult] = useState<BulkImportResult | null>(null);
const [bulkError, setBulkError] = useState<string | null>(null);
useEffect(() => {
if (currentUser) {
@ -68,6 +74,9 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
setActiveModal(null);
setServerForm(defaultServerForm);
setProfileForm((prev) => ({ ...prev, password: "" }));
setBulkFile(null);
setBulkResult(null);
setBulkError(null);
};
const handleServerFormChange = (event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
@ -128,6 +137,47 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
}
};
const handleBulkSubmit = async () => {
if (!bulkFile) {
toast.error("Selecione um arquivo CSV para importar.");
return;
}
setBulkLoading(true);
setBulkError(null);
try {
const formData = new FormData();
formData.append("file", bulkFile);
const { data } = await api.post<BulkImportResult>("/api/servers/bulk", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
setBulkResult(data);
toast.success(`Importação concluída: ${data.succeeded} sucesso(s).`);
await Promise.resolve(onServerCreated?.());
} catch (err: any) {
const message = err?.response?.data?.message || "Falha ao importar servidores.";
setBulkError(message);
toast.error(message);
} finally {
setBulkLoading(false);
}
};
const handleDownloadTemplate = () => {
const sample = [
"name;ip;port;user;password;type;application;dbType",
"app-server;192.168.0.10;22;deploy;changeMe;PRODUCTION;HITMANAGER;POSTGRESQL",
].join("\n");
const blob = new Blob([sample], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "servers_template.csv";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<>
<header className={Styles.wrapper}>
@ -137,6 +187,7 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
onToggleMenu={toggleMenu}
onAddServer={() => openModal("addServer")}
onEditProfile={() => openModal("editProfile")}
onBulkCreate={() => openModal("bulkImport")}
/>
</header>
@ -162,6 +213,18 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
onChange={handleProfileFormChange}
onSubmit={handleProfileSubmit}
/>
<BulkImportModal
isOpen={activeModal === "bulkImport"}
file={bulkFile}
loading={bulkLoading}
result={bulkResult}
error={bulkError}
onClose={closeModal}
onFileChange={setBulkFile}
onSubmit={handleBulkSubmit}
onDownloadTemplate={handleDownloadTemplate}
/>
</>
);
};

View File

@ -0,0 +1,153 @@
import type { ChangeEvent } from "react";
import type { BulkImportResult } from "../../types/BulkImport";
interface BulkImportModalProps {
isOpen: boolean;
file: File | null;
loading: boolean;
result: BulkImportResult | null;
error: string | null;
onClose: () => void;
onFileChange: (file: File | null) => void;
onSubmit: () => void;
onDownloadTemplate: () => void;
}
export const BulkImportModal = ({
isOpen,
file,
loading,
result,
error,
onClose,
onFileChange,
onSubmit,
onDownloadTemplate,
}: BulkImportModalProps) => {
if (!isOpen) return null;
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const selected = event.target.files?.[0];
onFileChange(selected ?? null);
};
return (
<div className={Styles.modalOverlay} role="dialog" aria-modal="true">
<div className={Styles.modal}>
<div className={Styles.modalHeader}>
<h2 className={Styles.modalTitle}>Cadastro em massa</h2>
<button type="button" onClick={onClose} className={Styles.closeButton} aria-label="Fechar modal">
×
</button>
</div>
<div className={Styles.modalBody}>
<div className={Styles.uploadCard}>
<label htmlFor="bulk-file" className={Styles.dropLabel}>
<span className="text-base font-medium text-text">Selecionar arquivo CSV</span>
<span className="text-xs text-text-secondary">
Arraste e solte ou clique para procurar
</span>
</label>
<input
id="bulk-file"
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={handleInputChange}
/>
<div className={Styles.dropzone} onClick={() => document.getElementById("bulk-file")?.click()}>
{file ? (
<>
<p className="text-sm font-medium text-text">{file.name}</p>
<p className="text-xs text-text-secondary">{(file.size / 1024).toFixed(1)} KB</p>
</>
) : (
<p className="text-sm text-text-secondary">Nenhum arquivo selecionado.</p>
)}
</div>
<p className={Styles.helperText}>
Estrutura esperada: <code>name;ip;port;user;password;type;application;dbType</code>
</p>
<div className={Styles.actionsRow}>
<button type="button" className={Styles.secondaryButton} onClick={onDownloadTemplate}>
Baixar CSV de exemplo
</button>
<button
type="button"
className={Styles.primaryButton}
disabled={!file || loading}
onClick={onSubmit}
>
{loading ? "Importando..." : "Importar arquivo"}
</button>
</div>
</div>
{error && <p className={Styles.errorText}>{error}</p>}
{result && (
<div className={Styles.resultCard}>
<div className="flex flex-wrap gap-4">
<Stat label="Processados" value={result.total} />
<Stat label="Sucesso" value={result.succeeded} accent />
<Stat label="Falhas" value={result.failed} danger />
</div>
{result.failed > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-text">Detalhes das falhas:</p>
<ul className={Styles.failureList}>
{result.failures.map((failure) => (
<li key={failure.line}>
<span className="font-semibold">Linha {failure.line}</span>: {failure.error}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
};
const Stat = ({
label,
value,
accent,
danger,
}: {
label: string;
value: number;
accent?: boolean;
danger?: boolean;
}) => (
<div
className={`flex flex-col rounded-lg border px-4 py-3 text-sm ${
accent ? "border-accent/40 bg-accent/10 text-accent" : danger ? "border-red-200 bg-red-50 text-red-600" : "border-cardBorder bg-white text-text"
}`}
>
<span className="text-xs uppercase tracking-wide opacity-70">{label}</span>
<span className="text-2xl font-bold leading-tight">{value}</span>
</div>
);
const Styles = {
modalOverlay: "fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4",
modal: "w-full max-w-2xl rounded-2xl border border-cardBorder bg-card p-6 shadow-xl",
modalHeader: "flex items-start justify-between gap-4 pb-4 border-b border-cardBorder",
modalTitle: "text-lg font-semibold text-text",
closeButton: "text-2xl leading-none text-text-secondary hover:text-text",
modalBody: "space-y-5 pt-4",
uploadCard: "rounded-xl border border-dashed border-cardBorder bg-white/70 p-6 shadow-inner space-y-4",
dropLabel: "flex flex-col gap-1 text-center",
dropzone: "flex flex-col items-center justify-center rounded-lg border border-cardBorder bg-bg px-4 py-6 text-center cursor-pointer hover:border-accent hover:bg-white transition-colors",
helperText: "text-xs text-text-secondary",
actionsRow: "flex flex-wrap items-center gap-3",
secondaryButton: "rounded-md border border-cardBorder px-4 py-2 text-sm font-medium text-text hover:bg-bg",
primaryButton: "rounded-md bg-accent px-4 py-2 text-sm font-semibold text-white hover:bg-hover disabled:opacity-70",
errorText: "text-sm text-red-600",
resultCard: "rounded-xl border border-cardBorder bg-white/90 p-5 space-y-4",
failureList: "list-disc pl-5 space-y-1 text-sm text-text-secondary max-h-40 overflow-auto",
};

View File

@ -3,10 +3,18 @@ interface HeaderActionsProps {
onToggleMenu: () => void;
onAddServer: () => void;
onEditProfile: () => void;
onBulkCreate: () => void;
onLogout?: () => void;
}
export const HeaderActions = ({ isMenuOpen, onToggleMenu, onAddServer, onEditProfile, onLogout }: HeaderActionsProps) => {
export const HeaderActions = ({
isMenuOpen,
onToggleMenu,
onAddServer,
onEditProfile,
onBulkCreate,
onLogout,
}: HeaderActionsProps) => {
return (
<div className={Styles.actions}>
<div className="relative">
@ -24,6 +32,9 @@ export const HeaderActions = ({ isMenuOpen, onToggleMenu, onAddServer, onEditPro
<button type="button" className={Styles.dropdownItem} onClick={onAddServer}>
Adicionar servidor
</button>
<button type="button" className={Styles.dropdownItem} onClick={onBulkCreate}>
Cadastro em massa
</button>
<button type="button" className={Styles.dropdownItem} onClick={onEditProfile}>
Editar perfil
</button>

View File

@ -0,0 +1,12 @@
export interface BulkImportFailure {
line: number;
error: string;
raw: string;
}
export interface BulkImportResult {
total: number;
succeeded: number;
failed: number;
failures: BulkImportFailure[];
}