Compare commits
11 Commits
a69aca5dc8
...
c665aa18ea
| Author | SHA1 | Date |
|---|---|---|
|
|
c665aa18ea | |
|
|
7460577423 | |
|
|
d4d65ad0f9 | |
|
|
a43fc58ff7 | |
|
|
bba78772db | |
|
|
25579ab7bd | |
|
|
6d5a64be89 | |
|
|
ed247c423e | |
|
|
f9b62dcc4e | |
|
|
d08e42732f | |
|
|
4efdfc9970 |
13
README.md
13
README.md
|
|
@ -31,6 +31,19 @@ Plataforma interna para catalogar servidores corporativos e facilitar consultas
|
||||||
3. **Testar via Postman**
|
3. **Testar via Postman**
|
||||||
Importe `postman_collection.json`. Rode "Auth / Login" para setar cookies e seguir para os demais endpoints.
|
Importe `postman_collection.json`. Rode "Auth / Login" para setar cookies e seguir para os demais endpoints.
|
||||||
|
|
||||||
|
## Executar com Docker
|
||||||
|
1. Crie um `.env` na pasta `frontend/` se precisar sobrescrever variáveis (opcional).
|
||||||
|
2. Construa e suba tudo (Postgres + backend + frontend):
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
3. Endpoints expostos:
|
||||||
|
- API: `http://localhost:8080`
|
||||||
|
- Swagger UI: `http://localhost:8080/swagger-ui.html`
|
||||||
|
- Frontend: `http://localhost:4173`
|
||||||
|
|
||||||
|
Variáveis sensíveis (ex.: `JWT_SECRET`, credenciais do banco) podem ser ajustadas diretamente no `docker-compose.yml` ou via arquivos `.env`.
|
||||||
|
|
||||||
## Documentação específica
|
## Documentação específica
|
||||||
- Backend: `backend/README.md`
|
- Backend: `backend/README.md`
|
||||||
- Frontend: `frontned/README.md`
|
- Frontend: `frontned/README.md`
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
build
|
||||||
|
.gradle
|
||||||
|
.idea
|
||||||
|
*.iml
|
||||||
|
*.log
|
||||||
|
HELP.md
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
FROM gradle:8.10.2-jdk21 AS builder
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
COPY gradlew .
|
||||||
|
COPY gradle gradle
|
||||||
|
COPY build.gradle settings.gradle ./
|
||||||
|
COPY src src
|
||||||
|
|
||||||
|
RUN chmod +x gradlew && ./gradlew bootJar --no-daemon
|
||||||
|
|
||||||
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV JAVA_OPTS=""
|
||||||
|
|
||||||
|
COPY --from=builder /workspace/build/libs/*.jar app.jar
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"]
|
||||||
|
|
@ -32,6 +32,20 @@ Seed: `src/main/resources/data.sql` cria usuário padrão `default@hittelco.com`
|
||||||
```
|
```
|
||||||
API em `http://localhost:8080` com CORS liberado para `http://localhost:5173`.
|
API em `http://localhost:8080` com CORS liberado para `http://localhost:5173`.
|
||||||
|
|
||||||
|
## Documentação OpenAPI
|
||||||
|
- Dependência `springdoc-openapi` habilita `/swagger-ui.html` (UI) e `/v3/api-docs` (JSON).
|
||||||
|
- Endpoints agora trazem `@Operation` e `@ApiResponses`, facilitando entendimento e testes.
|
||||||
|
- Para testar rotas protegidas via Swagger UI, execute `/api/auth/login` pela própria interface; os cookies emitidos serão armazenados no navegador e enviados nas requisições seguintes.
|
||||||
|
|
||||||
|
## Formatação com Spotless
|
||||||
|
O projeto usa [Spotless](https://github.com/diffplug/spotless) para padronizar o código Java (imports, formatação Google Java Format).
|
||||||
|
|
||||||
|
Comandos úteis:
|
||||||
|
```bash
|
||||||
|
./gradlew spotlessCheck # valida formatação (executado em pipelines via 'check')
|
||||||
|
./gradlew spotlessApply # ajusta os arquivos automaticamente
|
||||||
|
```
|
||||||
|
|
||||||
## Testes
|
## Testes
|
||||||
```bash
|
```bash
|
||||||
./gradlew test
|
./gradlew test
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ plugins {
|
||||||
id 'java'
|
id 'java'
|
||||||
id 'org.springframework.boot' version '4.0.0'
|
id 'org.springframework.boot' version '4.0.0'
|
||||||
id 'io.spring.dependency-management' version '1.1.7'
|
id 'io.spring.dependency-management' version '1.1.7'
|
||||||
|
id 'com.diffplug.spotless' version '6.25.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
group = 'com.hitcommunications'
|
group = 'com.hitcommunications'
|
||||||
|
|
@ -33,6 +34,7 @@ dependencies {
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
|
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
|
||||||
implementation 'org.mapstruct:mapstruct:1.6.3'
|
implementation 'org.mapstruct:mapstruct:1.6.3'
|
||||||
|
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
|
||||||
compileOnly 'org.projectlombok:lombok'
|
compileOnly 'org.projectlombok:lombok'
|
||||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||||
runtimeOnly 'com.h2database:h2'
|
runtimeOnly 'com.h2database:h2'
|
||||||
|
|
@ -51,3 +53,16 @@ dependencies {
|
||||||
tasks.named('test') {
|
tasks.named('test') {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
spotless {
|
||||||
|
java {
|
||||||
|
target 'src/**/*.java'
|
||||||
|
importOrder()
|
||||||
|
removeUnusedImports()
|
||||||
|
googleJavaFormat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named('check') {
|
||||||
|
dependsOn 'spotlessCheck'
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.hitcommunications.servermanager.config;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||||
|
import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn;
|
||||||
|
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
|
||||||
|
import io.swagger.v3.oas.annotations.info.Info;
|
||||||
|
import io.swagger.v3.oas.annotations.security.SecurityScheme;
|
||||||
|
import org.springdoc.core.models.GroupedOpenApi;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@OpenAPIDefinition(
|
||||||
|
info = @Info(
|
||||||
|
title = "Servers Manager API",
|
||||||
|
version = "v1",
|
||||||
|
description = "Catálogo interno de servidores, usuários e integrações."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@SecurityScheme(
|
||||||
|
name = "jwt-auth",
|
||||||
|
type = SecuritySchemeType.APIKEY,
|
||||||
|
in = SecuritySchemeIn.COOKIE,
|
||||||
|
paramName = "access_token",
|
||||||
|
description = "O login emite o cookie 'access_token'. O Swagger UI reutiliza automaticamente esse cookie nas rotas protegidas."
|
||||||
|
)
|
||||||
|
public class OpenApiConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public GroupedOpenApi serversManagerApi() {
|
||||||
|
return GroupedOpenApi.builder()
|
||||||
|
.group("servers-manager")
|
||||||
|
.pathsToMatch("/api/**")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,7 +40,15 @@ public class SecurityConfig {
|
||||||
.csrf(csrf -> csrf.disable())
|
.csrf(csrf -> csrf.disable())
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/", "/api/auth/login", "/api/auth/refresh").permitAll()
|
.requestMatchers(
|
||||||
|
"/",
|
||||||
|
"/api/auth/login",
|
||||||
|
"/api/auth/refresh",
|
||||||
|
"/api/auth/logout",
|
||||||
|
"/swagger-ui.html",
|
||||||
|
"/swagger-ui/**",
|
||||||
|
"/v3/api-docs/**"
|
||||||
|
).permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.authenticationProvider(authenticationProvider())
|
.authenticationProvider(authenticationProvider())
|
||||||
|
|
@ -72,7 +80,9 @@ public class SecurityConfig {
|
||||||
CorsConfiguration configuration = new CorsConfiguration();
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
configuration.setAllowedOrigins(List.of(
|
configuration.setAllowedOrigins(List.of(
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
"http://127.0.0.1:5173"
|
"http://127.0.0.1:5173",
|
||||||
|
"http://localhost:4173",
|
||||||
|
"http://127.0.0.1:4173"
|
||||||
));
|
));
|
||||||
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
configuration.setAllowedHeaders(List.of("*"));
|
configuration.setAllowedHeaders(List.of("*"));
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ import com.hitcommunications.servermanager.model.dtos.NewUserDTO;
|
||||||
import com.hitcommunications.servermanager.model.dtos.UserDTO;
|
import com.hitcommunications.servermanager.model.dtos.UserDTO;
|
||||||
import com.hitcommunications.servermanager.services.AuthService;
|
import com.hitcommunications.servermanager.services.AuthService;
|
||||||
import com.hitcommunications.servermanager.utils.CookieUtils;
|
import com.hitcommunications.servermanager.utils.CookieUtils;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
@ -20,9 +25,12 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/auth")
|
@RequestMapping("/api/auth")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Authentication")
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
public static final String REFRESH_TOKEN_COOKIE = "refresh_token";
|
public static final String REFRESH_TOKEN_COOKIE = "refresh_token";
|
||||||
|
|
@ -31,12 +39,22 @@ public class AuthController {
|
||||||
private final JwtProperties jwtProperties;
|
private final JwtProperties jwtProperties;
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
|
@Operation(summary = "Autentica usuário e emite cookies JWT.")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "Login realizado e cookies emitidos."),
|
||||||
|
@ApiResponse(responseCode = "401", description = "Credenciais inválidas.")
|
||||||
|
})
|
||||||
public ResponseEntity<AuthResponse> login(@RequestBody @Valid LoginRequest request, HttpServletRequest httpRequest) {
|
public ResponseEntity<AuthResponse> login(@RequestBody @Valid LoginRequest request, HttpServletRequest httpRequest) {
|
||||||
AuthTokens tokens = authService.login(request);
|
AuthTokens tokens = authService.login(request);
|
||||||
return buildResponse(tokens, httpRequest);
|
return buildResponse(tokens, httpRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/refresh")
|
@PostMapping("/refresh")
|
||||||
|
@Operation(summary = "Renova os cookies de sessão a partir do refresh token.")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "Tokens renovados com sucesso."),
|
||||||
|
@ApiResponse(responseCode = "401", description = "Refresh token inválido ou ausente.")
|
||||||
|
})
|
||||||
public ResponseEntity<AuthResponse> refresh(HttpServletRequest request) {
|
public ResponseEntity<AuthResponse> refresh(HttpServletRequest request) {
|
||||||
String refreshToken = CookieUtils.getCookieValue(request, REFRESH_TOKEN_COOKIE);
|
String refreshToken = CookieUtils.getCookieValue(request, REFRESH_TOKEN_COOKIE);
|
||||||
AuthTokens tokens = authService.refresh(refreshToken);
|
AuthTokens tokens = authService.refresh(refreshToken);
|
||||||
|
|
@ -44,11 +62,46 @@ public class AuthController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/signup")
|
@PostMapping("/signup")
|
||||||
|
@Operation(summary = "Cria um novo usuário autenticado.")
|
||||||
|
@SecurityRequirement(name = "jwt-auth")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "Usuário criado com sucesso."),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Dados inválidos."),
|
||||||
|
@ApiResponse(responseCode = "409", description = "Email já utilizado.")
|
||||||
|
})
|
||||||
public ResponseEntity<UserDTO> signup(@RequestBody @Valid NewUserDTO request) throws IllegalAccessException {
|
public ResponseEntity<UserDTO> signup(@RequestBody @Valid NewUserDTO request) throws IllegalAccessException {
|
||||||
UserDTO user = authService.signup(request);
|
UserDTO user = authService.signup(request);
|
||||||
return ResponseEntity.ok(user);
|
return ResponseEntity.ok(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/logout")
|
||||||
|
@Operation(summary = "Remove tokens de sessão e encerra o login atual.")
|
||||||
|
@SecurityRequirement(name = "jwt-auth")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "204", description = "Sessão encerrada.")
|
||||||
|
})
|
||||||
|
public ResponseEntity<Void> logout(HttpServletRequest request) {
|
||||||
|
boolean secure = request.isSecure();
|
||||||
|
ResponseCookie expiredAccessCookie = CookieUtils.buildCookie(
|
||||||
|
JwtAuthenticationFilter.ACCESS_TOKEN_COOKIE,
|
||||||
|
"",
|
||||||
|
Duration.ZERO,
|
||||||
|
false,
|
||||||
|
secure
|
||||||
|
);
|
||||||
|
ResponseCookie expiredRefreshCookie = CookieUtils.buildCookie(
|
||||||
|
REFRESH_TOKEN_COOKIE,
|
||||||
|
"",
|
||||||
|
Duration.ZERO,
|
||||||
|
true,
|
||||||
|
secure
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.noContent()
|
||||||
|
.header(HttpHeaders.SET_COOKIE, expiredAccessCookie.toString(), expiredRefreshCookie.toString())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
private ResponseEntity<AuthResponse> buildResponse(AuthTokens tokens, HttpServletRequest request) {
|
private ResponseEntity<AuthResponse> buildResponse(AuthTokens tokens, HttpServletRequest request) {
|
||||||
boolean secure = request.isSecure();
|
boolean secure = request.isSecure();
|
||||||
ResponseCookie accessCookie = CookieUtils.buildCookie(
|
ResponseCookie accessCookie = CookieUtils.buildCookie(
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,25 @@ 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.enums.Applications;
|
import com.hitcommunications.servermanager.model.dtos.PagedResponse;
|
||||||
import com.hitcommunications.servermanager.model.enums.ServersType;
|
|
||||||
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.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -18,56 +30,104 @@ import java.util.Map;
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/servers")
|
@RequestMapping("/api/servers")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Servers")
|
||||||
|
@SecurityRequirement(name = "jwt-auth")
|
||||||
public class ServersController {
|
public class ServersController {
|
||||||
|
|
||||||
private final ServersService serversService;
|
private final ServersService serversService;
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
|
@Operation(summary = "Cria um novo servidor.")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "Servidor criado."),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Dados inválidos."),
|
||||||
|
@ApiResponse(responseCode = "409", description = "Servidor duplicado.")
|
||||||
|
})
|
||||||
public ResponseEntity<ServerDTO> create(@RequestBody @Valid NewServerDTO createDTO) {
|
public ResponseEntity<ServerDTO> create(@RequestBody @Valid NewServerDTO createDTO) {
|
||||||
return ResponseEntity.ok().body(serversService.create(createDTO));
|
return ResponseEntity.ok().body(serversService.create(createDTO));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Busca servidor pelo ID.")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "Servidor encontrado."),
|
||||||
|
@ApiResponse(responseCode = "404", description = "Servidor não encontrado.")
|
||||||
|
})
|
||||||
public ResponseEntity<ServerDTO> getById(@PathVariable String id) {
|
public ResponseEntity<ServerDTO> getById(@PathVariable String id) {
|
||||||
return ResponseEntity.ok().body(serversService.getById(id));
|
return ResponseEntity.ok().body(serversService.getById(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/name/{name}")
|
@GetMapping("/name/{name}")
|
||||||
|
@Operation(summary = "Busca servidor pelo nome.")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "Servidor encontrado."),
|
||||||
|
@ApiResponse(responseCode = "404", description = "Servidor não encontrado.")
|
||||||
|
})
|
||||||
public ResponseEntity<ServerDTO> getByName(@PathVariable String name) {
|
public ResponseEntity<ServerDTO> getByName(@PathVariable String name) {
|
||||||
return ResponseEntity.ok().body(serversService.getByName(name));
|
return ResponseEntity.ok().body(serversService.getByName(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/type/{type}")
|
@GetMapping("/type/{type}")
|
||||||
public ResponseEntity<List<ServerDTO>> getByType(@PathVariable ServersType type) {
|
@Operation(summary = "Lista servidores por tipo.")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Lista retornada.")
|
||||||
|
public ResponseEntity<List<ServerDTO>> getByType(@PathVariable String type) {
|
||||||
return ResponseEntity.ok().body(serversService.getByType(type));
|
return ResponseEntity.ok().body(serversService.getByType(type));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/type")
|
@GetMapping("/type")
|
||||||
public ResponseEntity<Map<ServersType, Integer>> countAllByType() {
|
@Operation(summary = "Conta servidores agrupando por tipo.")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Mapa retornado.")
|
||||||
|
public ResponseEntity<Map<String, Integer>> countAllByType() {
|
||||||
return ResponseEntity.ok().body(serversService.countAllByType());
|
return ResponseEntity.ok().body(serversService.countAllByType());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/bulk")
|
@PostMapping("/bulk")
|
||||||
|
@Operation(summary = "Realiza importação em massa via CSV.")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "Arquivo processado."),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Arquivo inválido.")
|
||||||
|
})
|
||||||
public ResponseEntity<BulkServerImportResponse> bulkCreate(@RequestParam("file") MultipartFile file) {
|
public ResponseEntity<BulkServerImportResponse> bulkCreate(@RequestParam("file") MultipartFile file) {
|
||||||
return ResponseEntity.ok().body(serversService.bulkCreate(file));
|
return ResponseEntity.ok().body(serversService.bulkCreate(file));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/application/{application}")
|
@GetMapping("/application/{application}")
|
||||||
public ResponseEntity<List<ServerDTO>> getByApplication(@PathVariable Applications application) {
|
@Operation(summary = "Lista servidores por aplicação.")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Lista retornada.")
|
||||||
|
public ResponseEntity<List<ServerDTO>> getByApplication(@PathVariable String application) {
|
||||||
return ResponseEntity.ok().body(serversService.getByApplication(application));
|
return ResponseEntity.ok().body(serversService.getByApplication(application));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<List<ServerDTO>> getAll() {
|
@Operation(summary = "Pesquisa servidores com filtros combinados.")
|
||||||
return ResponseEntity.ok().body(serversService.getAll());
|
@ApiResponse(responseCode = "200", description = "Resultados retornados.")
|
||||||
|
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 = "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, page, size));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
|
@Operation(summary = "Atualiza um servidor existente.")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "Servidor atualizado."),
|
||||||
|
@ApiResponse(responseCode = "404", description = "Servidor não encontrado.")
|
||||||
|
})
|
||||||
public ResponseEntity<ServerDTO> update(@PathVariable String id, @RequestBody @Valid NewServerDTO updateDTO) {
|
public ResponseEntity<ServerDTO> update(@PathVariable String id, @RequestBody @Valid NewServerDTO updateDTO) {
|
||||||
return ResponseEntity.ok().body(serversService.update(id, updateDTO));
|
return ResponseEntity.ok().body(serversService.update(id, updateDTO));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
|
@Operation(summary = "Remove um servidor.")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "204", description = "Servidor removido."),
|
||||||
|
@ApiResponse(responseCode = "404", description = "Servidor não encontrado.")
|
||||||
|
})
|
||||||
public ResponseEntity<Void> delete(@PathVariable String id) {
|
public ResponseEntity<Void> delete(@PathVariable String id) {
|
||||||
serversService.delete(id);
|
serversService.delete(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package com.hitcommunications.servermanager.controllers;
|
||||||
|
|
||||||
|
import com.hitcommunications.servermanager.model.enums.TypeCategory;
|
||||||
|
import com.hitcommunications.servermanager.services.TypeOptionService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/type-options")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Type Options")
|
||||||
|
public class TypeOptionsController {
|
||||||
|
|
||||||
|
private final TypeOptionService typeOptionService;
|
||||||
|
|
||||||
|
@GetMapping("/{category}")
|
||||||
|
@Operation(summary = "Lista os valores disponíveis para um tipo de categoria (SERVER_TYPE, APPLICATION, DATABASE).")
|
||||||
|
public ResponseEntity<List<String>> list(@PathVariable TypeCategory category) {
|
||||||
|
return ResponseEntity.ok(typeOptionService.list(category));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,21 @@ package com.hitcommunications.servermanager.controllers;
|
||||||
import com.hitcommunications.servermanager.model.dtos.NewUserDTO;
|
import com.hitcommunications.servermanager.model.dtos.NewUserDTO;
|
||||||
import com.hitcommunications.servermanager.model.dtos.UserDTO;
|
import com.hitcommunications.servermanager.model.dtos.UserDTO;
|
||||||
import com.hitcommunications.servermanager.services.UsersService;
|
import com.hitcommunications.servermanager.services.UsersService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
@ -14,36 +25,66 @@ import java.util.UUID;
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/users")
|
@RequestMapping("/api/users")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Users")
|
||||||
|
@SecurityRequirement(name = "jwt-auth")
|
||||||
public class UsersController {
|
public class UsersController {
|
||||||
|
|
||||||
private final UsersService usersService;
|
private final UsersService usersService;
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Busca usuário pelo ID.")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "Usuário encontrado."),
|
||||||
|
@ApiResponse(responseCode = "404", description = "Usuário não encontrado.")
|
||||||
|
})
|
||||||
public ResponseEntity<UserDTO> getById(@PathVariable UUID id) {
|
public ResponseEntity<UserDTO> getById(@PathVariable UUID id) {
|
||||||
return ResponseEntity.ok().body(usersService.getById(id));
|
return ResponseEntity.ok().body(usersService.getById(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/username/{username}")
|
@GetMapping("/username/{username}")
|
||||||
|
@Operation(summary = "Busca usuário pelo username.")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "Usuário encontrado."),
|
||||||
|
@ApiResponse(responseCode = "404", description = "Usuário não encontrado.")
|
||||||
|
})
|
||||||
public ResponseEntity<UserDTO> getByUsername(@PathVariable String username) {
|
public ResponseEntity<UserDTO> getByUsername(@PathVariable String username) {
|
||||||
return ResponseEntity.ok().body(usersService.getByUsername(username));
|
return ResponseEntity.ok().body(usersService.getByUsername(username));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/email/{email}")
|
@GetMapping("/email/{email}")
|
||||||
|
@Operation(summary = "Busca usuário pelo email.")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "Usuário encontrado."),
|
||||||
|
@ApiResponse(responseCode = "404", description = "Usuário não encontrado.")
|
||||||
|
})
|
||||||
public ResponseEntity<UserDTO> getByEmail(@PathVariable String email) {
|
public ResponseEntity<UserDTO> getByEmail(@PathVariable String email) {
|
||||||
return ResponseEntity.ok().body(usersService.getByEmail(email));
|
return ResponseEntity.ok().body(usersService.getByEmail(email));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
|
@Operation(summary = "Lista todos os usuários.")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Lista retornada.")
|
||||||
public ResponseEntity<List<UserDTO>> getAll() {
|
public ResponseEntity<List<UserDTO>> getAll() {
|
||||||
return ResponseEntity.ok().body(usersService.getAll());
|
return ResponseEntity.ok().body(usersService.getAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
|
@Operation(summary = "Atualiza os dados de um usuário.")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "Usuário atualizado."),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Dados inválidos."),
|
||||||
|
@ApiResponse(responseCode = "404", description = "Usuário não encontrado.")
|
||||||
|
})
|
||||||
public ResponseEntity<UserDTO> update(@PathVariable UUID id, @RequestBody @Valid NewUserDTO updateDTO) throws IllegalAccessException {
|
public ResponseEntity<UserDTO> update(@PathVariable UUID id, @RequestBody @Valid NewUserDTO updateDTO) throws IllegalAccessException {
|
||||||
return ResponseEntity.ok().body(usersService.update(id, updateDTO));
|
return ResponseEntity.ok().body(usersService.update(id, updateDTO));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
|
@Operation(summary = "Remove um usuário.")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "204", description = "Usuário removido."),
|
||||||
|
@ApiResponse(responseCode = "404", description = "Usuário não encontrado.")
|
||||||
|
})
|
||||||
public ResponseEntity<Void> delete(@PathVariable UUID id) {
|
public ResponseEntity<Void> delete(@PathVariable UUID id) {
|
||||||
usersService.delete(id);
|
usersService.delete(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
package com.hitcommunications.servermanager.model;
|
package com.hitcommunications.servermanager.model;
|
||||||
|
|
||||||
import com.hitcommunications.servermanager.model.enums.Applications;
|
import java.sql.Timestamp;
|
||||||
import com.hitcommunications.servermanager.model.enums.DatabaseType;
|
|
||||||
import com.hitcommunications.servermanager.model.enums.ServersType;
|
|
||||||
import com.hitcommunications.servermanager.utils.ServerIdGenerator;
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.*;
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
import java.sql.Timestamp;
|
import com.hitcommunications.servermanager.utils.ServerIdGenerator;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "tab_servers")
|
@Table(name = "tab_servers")
|
||||||
|
|
@ -28,7 +34,7 @@ public class Servers {
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false, columnDefinition = "VARCHAR(45)")
|
||||||
private String ip;
|
private String ip;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
|
|
@ -40,17 +46,14 @@ public class Servers {
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false, length = 80)
|
||||||
@Enumerated(EnumType.STRING)
|
private String type;
|
||||||
private ServersType type;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false, length = 80)
|
||||||
@Enumerated(EnumType.STRING)
|
private String application;
|
||||||
private Applications application;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false, length = 80, name = "db_type")
|
||||||
@Enumerated(EnumType.STRING)
|
private String dbType;
|
||||||
private DatabaseType dbType;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
private Timestamp createdAt;
|
private Timestamp createdAt;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package com.hitcommunications.servermanager.model;
|
||||||
|
|
||||||
|
import com.hitcommunications.servermanager.model.enums.TypeCategory;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "tab_type_options", schema = "server-manager", uniqueConstraints = {
|
||||||
|
@UniqueConstraint(columnNames = {"category", "value"})
|
||||||
|
})
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class TypeOption {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(nullable = false, updatable = false)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 32)
|
||||||
|
private TypeCategory category;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 80)
|
||||||
|
private String value;
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
package com.hitcommunications.servermanager.model.dtos;
|
package com.hitcommunications.servermanager.model.dtos;
|
||||||
|
|
||||||
import com.hitcommunications.servermanager.model.enums.Applications;
|
|
||||||
import com.hitcommunications.servermanager.model.enums.DatabaseType;
|
|
||||||
import com.hitcommunications.servermanager.model.enums.ServersType;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
|
@ -12,9 +9,8 @@ public record NewServerDTO(
|
||||||
@NotNull Integer port,
|
@NotNull Integer port,
|
||||||
@NotBlank String user,
|
@NotBlank String user,
|
||||||
@NotBlank String password,
|
@NotBlank String password,
|
||||||
@NotNull ServersType type,
|
@NotBlank String type,
|
||||||
@NotNull Applications application,
|
@NotBlank String application,
|
||||||
@NotNull DatabaseType dbType
|
@NotBlank String dbType
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,9 +1,5 @@
|
||||||
package com.hitcommunications.servermanager.model.dtos;
|
package com.hitcommunications.servermanager.model.dtos;
|
||||||
|
|
||||||
import com.hitcommunications.servermanager.model.enums.Applications;
|
|
||||||
import com.hitcommunications.servermanager.model.enums.DatabaseType;
|
|
||||||
import com.hitcommunications.servermanager.model.enums.ServersType;
|
|
||||||
|
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
|
|
||||||
public record ServerDTO(
|
public record ServerDTO(
|
||||||
|
|
@ -12,11 +8,11 @@ public record ServerDTO(
|
||||||
String ip,
|
String ip,
|
||||||
Integer port,
|
Integer port,
|
||||||
String user,
|
String user,
|
||||||
ServersType type,
|
String password,
|
||||||
Applications application,
|
String type,
|
||||||
DatabaseType dbType,
|
String application,
|
||||||
|
String dbType,
|
||||||
Timestamp createdAt,
|
Timestamp createdAt,
|
||||||
Timestamp updatedAt
|
Timestamp updatedAt
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
package com.hitcommunications.servermanager.model.enums;
|
|
||||||
|
|
||||||
public enum Applications {
|
|
||||||
ASTERISK,
|
|
||||||
HITMANAGER,
|
|
||||||
HITMANAGER_V2,
|
|
||||||
OMNIHIT,
|
|
||||||
HITPHONE
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
package com.hitcommunications.servermanager.model.enums;
|
|
||||||
|
|
||||||
public enum DatabaseType {
|
|
||||||
MYSQL,
|
|
||||||
POSTGRESQL,
|
|
||||||
SQLSERVER,
|
|
||||||
ORACLE,
|
|
||||||
REDIS,
|
|
||||||
MONGODB,
|
|
||||||
MARIADB,
|
|
||||||
NONE
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package com.hitcommunications.servermanager.model.enums;
|
package com.hitcommunications.servermanager.model.enums;
|
||||||
|
|
||||||
public enum ServersType {
|
public enum TypeCategory {
|
||||||
PRODUCTION,
|
SERVER_TYPE,
|
||||||
HOMOLOGATION,
|
APPLICATION,
|
||||||
DATABASE
|
DATABASE
|
||||||
}
|
}
|
||||||
|
|
@ -1,18 +1,59 @@
|
||||||
package com.hitcommunications.servermanager.repositories;
|
package com.hitcommunications.servermanager.repositories;
|
||||||
|
|
||||||
import com.hitcommunications.servermanager.model.Servers;
|
import com.hitcommunications.servermanager.model.Servers;
|
||||||
import com.hitcommunications.servermanager.model.enums.Applications;
|
|
||||||
import com.hitcommunications.servermanager.model.enums.ServersType;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
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.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
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(ServersType type);
|
List<Servers> findByType(String type);
|
||||||
List<Servers> findByApplication(Applications application);
|
List<Servers> findByApplication(String application);
|
||||||
Optional<Servers> findByIpAndPort(String ip, Integer port);
|
Optional<Servers> findByIpAndPort(String ip, Integer port);
|
||||||
Integer countAllByType(ServersType type);
|
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)
|
||||||
|
""",
|
||||||
|
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,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.hitcommunications.servermanager.repositories;
|
||||||
|
|
||||||
|
import com.hitcommunications.servermanager.model.TypeOption;
|
||||||
|
import com.hitcommunications.servermanager.model.enums.TypeCategory;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface TypeOptionRepository extends JpaRepository<TypeOption, Long> {
|
||||||
|
Optional<TypeOption> findByCategoryAndValue(TypeCategory category, String value);
|
||||||
|
List<TypeOption> findAllByCategoryOrderByValueAsc(TypeCategory category);
|
||||||
|
}
|
||||||
|
|
@ -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.enums.Applications;
|
import com.hitcommunications.servermanager.model.dtos.PagedResponse;
|
||||||
import com.hitcommunications.servermanager.model.enums.DatabaseType;
|
import com.hitcommunications.servermanager.model.enums.TypeCategory;
|
||||||
import com.hitcommunications.servermanager.model.enums.ServersType;
|
|
||||||
import com.hitcommunications.servermanager.repositories.ServersRepository;
|
import com.hitcommunications.servermanager.repositories.ServersRepository;
|
||||||
|
import com.hitcommunications.servermanager.services.TypeOptionService;
|
||||||
|
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;
|
||||||
|
|
||||||
|
|
@ -18,24 +22,23 @@ import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
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;
|
||||||
|
|
||||||
public ServerDTO create(NewServerDTO createDTO) {
|
public ServerDTO create(NewServerDTO createDTO) {
|
||||||
// Check if server with same IP and port already exists
|
|
||||||
repo.findByIpAndPort(createDTO.ip(), createDTO.port()).ifPresent(entity -> {
|
|
||||||
throw new RuntimeException("Server already exists with IP: " + createDTO.ip() + " and port: " + createDTO.port());
|
|
||||||
});
|
|
||||||
|
|
||||||
Servers entity = mapper.toEntity(createDTO);
|
Servers entity = mapper.toEntity(createDTO);
|
||||||
|
applyNormalizedTypeData(entity, createDTO);
|
||||||
entity = repo.save(entity);
|
entity = repo.save(entity);
|
||||||
return mapper.toDTO(entity);
|
return mapper.toDTO(entity);
|
||||||
}
|
}
|
||||||
|
|
@ -52,25 +55,34 @@ public class ServersService {
|
||||||
.orElseThrow(() -> new RuntimeException("Server not found with name: " + name));
|
.orElseThrow(() -> new RuntimeException("Server not found with name: " + name));
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ServerDTO> getByType(ServersType type) {
|
public List<ServerDTO> getByType(String type) {
|
||||||
return repo.findByType(type)
|
String normalized = normalizeFilter(type, TypeCategory.SERVER_TYPE);
|
||||||
|
return repo.findByType(normalized)
|
||||||
.stream()
|
.stream()
|
||||||
.map(mapper::toDTO)
|
.map(mapper::toDTO)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<ServersType, Integer> countAllByType() {
|
public Map<String, Integer> countAllByType() {
|
||||||
Map<ServersType, Integer> response = new HashMap<>();
|
Map<String, Integer> response = new LinkedHashMap<>();
|
||||||
|
|
||||||
for(ServersType type : ServersType.values()) {
|
typeOptionService.list(TypeCategory.SERVER_TYPE).forEach(type -> {
|
||||||
response.put(type, repo.countAllByType(type));
|
response.put(type, repo.countAllByType(type));
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// Garantir que valores existentes, mas não registrados, também apareçam
|
||||||
|
repo.findAll().stream()
|
||||||
|
.map(Servers::getType)
|
||||||
|
.filter(type -> type != null && !response.containsKey(type))
|
||||||
|
.distinct()
|
||||||
|
.forEach(type -> response.put(type, repo.countAllByType(type)));
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ServerDTO> getByApplication(Applications application) {
|
public List<ServerDTO> getByApplication(String application) {
|
||||||
return repo.findByApplication(application)
|
String normalized = normalizeFilter(application, TypeCategory.APPLICATION);
|
||||||
|
return repo.findByApplication(normalized)
|
||||||
.stream()
|
.stream()
|
||||||
.map(mapper::toDTO)
|
.map(mapper::toDTO)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
@ -97,6 +109,7 @@ public class ServersService {
|
||||||
|
|
||||||
processed++;
|
processed++;
|
||||||
try {
|
try {
|
||||||
|
registerTypeOptionsFromRow(columns);
|
||||||
NewServerDTO dto = toNewServerDTO(columns);
|
NewServerDTO dto = toNewServerDTO(columns);
|
||||||
create(dto);
|
create(dto);
|
||||||
succeeded++;
|
succeeded++;
|
||||||
|
|
@ -131,13 +144,29 @@ public class ServersService {
|
||||||
Integer port = Integer.parseInt(columns[2]);
|
Integer port = Integer.parseInt(columns[2]);
|
||||||
String user = columns[3];
|
String user = columns[3];
|
||||||
String password = columns[4];
|
String password = columns[4];
|
||||||
ServersType type = ServersType.valueOf(columns[5].toUpperCase());
|
String type = columns[5];
|
||||||
Applications application = Applications.valueOf(columns[6].toUpperCase());
|
String application = columns[6];
|
||||||
DatabaseType dbType = DatabaseType.valueOf(columns[7].toUpperCase());
|
String dbType = columns[7];
|
||||||
|
|
||||||
return new NewServerDTO(name, ip, port, user, password, type, application, dbType);
|
return new NewServerDTO(name, ip, port, user, password, type, application, dbType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void registerTypeOptionsFromRow(String[] columns) {
|
||||||
|
if (columns.length < 8) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
registerTypeOption(TypeCategory.SERVER_TYPE, columns[5]);
|
||||||
|
registerTypeOption(TypeCategory.APPLICATION, columns[6]);
|
||||||
|
registerTypeOption(TypeCategory.DATABASE, columns[7]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void registerTypeOption(TypeCategory category, String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
typeOptionService.register(category, value);
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isHeaderRow(String[] columns) {
|
private boolean isHeaderRow(String[] columns) {
|
||||||
if (columns.length < 8) {
|
if (columns.length < 8) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -154,18 +183,37 @@ public class ServersService {
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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) {
|
public ServerDTO update(String id, NewServerDTO updateDTO) {
|
||||||
Servers entity = repo.findById(id)
|
Servers entity = repo.findById(id)
|
||||||
.orElseThrow(() -> new RuntimeException("Server not found with id: " + id));
|
.orElseThrow(() -> new RuntimeException("Server not found with id: " + id));
|
||||||
|
|
||||||
// Check if IP/port combination already exists (excluding current server)
|
|
||||||
repo.findByIpAndPort(updateDTO.ip(), updateDTO.port()).ifPresent(existingServer -> {
|
|
||||||
if (!existingServer.getId().equals(id)) {
|
|
||||||
throw new RuntimeException("Server already exists with IP: " + updateDTO.ip() + " and port: " + updateDTO.port());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
mapper.partialUpdate(updateDTO, entity);
|
mapper.partialUpdate(updateDTO, entity);
|
||||||
|
applyNormalizedTypeData(entity, updateDTO);
|
||||||
entity = repo.save(entity);
|
entity = repo.save(entity);
|
||||||
return mapper.toDTO(entity);
|
return mapper.toDTO(entity);
|
||||||
}
|
}
|
||||||
|
|
@ -176,4 +224,31 @@ public class ServersService {
|
||||||
}
|
}
|
||||||
repo.deleteById(id);
|
repo.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void applyNormalizedTypeData(Servers entity, NewServerDTO dto) {
|
||||||
|
entity.setType(normalizeAndRegister(TypeCategory.SERVER_TYPE, dto.type()));
|
||||||
|
entity.setApplication(normalizeAndRegister(TypeCategory.APPLICATION, dto.application()));
|
||||||
|
entity.setDbType(normalizeAndRegister(TypeCategory.DATABASE, dto.dbType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeFilter(String value, TypeCategory category) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String normalized = TypeNormalizer.normalize(value);
|
||||||
|
if (normalized == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
typeOptionService.register(category, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeAndRegister(TypeCategory category, String value) {
|
||||||
|
String normalized = TypeNormalizer.normalize(value);
|
||||||
|
if (normalized == null) {
|
||||||
|
throw new IllegalArgumentException("Valor inválido para " + category.name());
|
||||||
|
}
|
||||||
|
typeOptionService.register(category, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package com.hitcommunications.servermanager.services;
|
||||||
|
|
||||||
|
import com.hitcommunications.servermanager.model.TypeOption;
|
||||||
|
import com.hitcommunications.servermanager.model.enums.TypeCategory;
|
||||||
|
import com.hitcommunications.servermanager.repositories.TypeOptionRepository;
|
||||||
|
import com.hitcommunications.servermanager.utils.TypeNormalizer;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TypeOptionService {
|
||||||
|
|
||||||
|
private final TypeOptionRepository repository;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void register(TypeCategory category, String rawValue) {
|
||||||
|
String normalized = TypeNormalizer.normalize(rawValue);
|
||||||
|
if (normalized == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.findByCategoryAndValue(category, normalized)
|
||||||
|
.orElseGet(() -> repository.save(
|
||||||
|
TypeOption.builder()
|
||||||
|
.category(category)
|
||||||
|
.value(normalized)
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> list(TypeCategory category) {
|
||||||
|
return repository.findAllByCategoryOrderByValueAsc(category)
|
||||||
|
.stream()
|
||||||
|
.map(TypeOption::getValue)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,11 +6,11 @@ import com.hitcommunications.servermanager.model.dtos.NewUserDTO;
|
||||||
import com.hitcommunications.servermanager.model.dtos.UserDTO;
|
import com.hitcommunications.servermanager.model.dtos.UserDTO;
|
||||||
import com.hitcommunications.servermanager.repositories.UsersRepository;
|
import com.hitcommunications.servermanager.repositories.UsersRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
|
@ -20,17 +20,11 @@ public class UsersService {
|
||||||
private final UsersRepository repo;
|
private final UsersRepository repo;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
private final String ALLOWED_DOMAIN = Arrays.asList("hittelco.com", "accesscommunications.com").toString();
|
private static final Set<String> ALLOWED_DOMAINS = Set.of("hittelco.com", "accesscommunications.com");
|
||||||
|
|
||||||
public UserDTO create(NewUserDTO createDTO) throws IllegalAccessException {
|
public UserDTO create(NewUserDTO createDTO) throws IllegalAccessException {
|
||||||
String domain = getDomain(createDTO.email());
|
validateAllowedDomain(createDTO.email());
|
||||||
if (!ALLOWED_DOMAIN.contains(domain)) {
|
ensureEmailIsUnique(createDTO.email(), null);
|
||||||
throw new IllegalAccessException("Email domain not allowed: " + domain);
|
|
||||||
}
|
|
||||||
|
|
||||||
repo.findByEmail(createDTO.email()).ifPresent(entity -> {
|
|
||||||
throw new RuntimeException("Email already exists: " + createDTO.email());
|
|
||||||
});
|
|
||||||
|
|
||||||
Users entity = mapper.toEntity(createDTO);
|
Users entity = mapper.toEntity(createDTO);
|
||||||
entity.setPassword(passwordEncoder.encode(createDTO.password()));
|
entity.setPassword(passwordEncoder.encode(createDTO.password()));
|
||||||
|
|
@ -67,17 +61,8 @@ public class UsersService {
|
||||||
Users entity = repo.findById(id)
|
Users entity = repo.findById(id)
|
||||||
.orElseThrow(() -> new RuntimeException("User not found with id: " + id));
|
.orElseThrow(() -> new RuntimeException("User not found with id: " + id));
|
||||||
|
|
||||||
String domain = getDomain(updateDTO.email());
|
validateAllowedDomain(updateDTO.email());
|
||||||
if (!ALLOWED_DOMAIN.contains(domain)) {
|
ensureEmailIsUnique(updateDTO.email(), id);
|
||||||
throw new IllegalAccessException("Email domain not allowed: " + domain);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if email already exists (excluding current user)
|
|
||||||
repo.findByEmail(updateDTO.email()).ifPresent(existingUser -> {
|
|
||||||
if (!existingUser.getId().equals(id)) {
|
|
||||||
throw new RuntimeException("Email already exists: " + updateDTO.email());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
mapper.partialUpdate(updateDTO, entity);
|
mapper.partialUpdate(updateDTO, entity);
|
||||||
entity.setPassword(passwordEncoder.encode(updateDTO.password()));
|
entity.setPassword(passwordEncoder.encode(updateDTO.password()));
|
||||||
|
|
@ -95,4 +80,19 @@ public class UsersService {
|
||||||
private String getDomain(String email) {
|
private String getDomain(String email) {
|
||||||
return email.substring(email.indexOf("@") + 1);
|
return email.substring(email.indexOf("@") + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validateAllowedDomain(String email) throws IllegalAccessException {
|
||||||
|
String domain = getDomain(email);
|
||||||
|
if (!ALLOWED_DOMAINS.contains(domain)) {
|
||||||
|
throw new IllegalAccessException("Email domain not allowed: " + domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureEmailIsUnique(String email, UUID currentUserId) {
|
||||||
|
repo.findByEmail(email).ifPresent(existingUser -> {
|
||||||
|
if (currentUserId == null || !existingUser.getId().equals(currentUserId)) {
|
||||||
|
throw new RuntimeException("Email already exists: " + email);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package com.hitcommunications.servermanager.utils;
|
||||||
|
|
||||||
|
import java.text.Normalizer;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
public final class TypeNormalizer {
|
||||||
|
|
||||||
|
private static final Pattern DIACRITICS = Pattern.compile("\\p{M}");
|
||||||
|
|
||||||
|
private TypeNormalizer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String normalize(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalized = Normalizer.normalize(trimmed, Normalizer.Form.NFD);
|
||||||
|
normalized = DIACRITICS.matcher(normalized).replaceAll("");
|
||||||
|
normalized = normalized.replaceAll("[^A-Za-z0-9_\\- ]", "");
|
||||||
|
normalized = normalized.replaceAll("\\s+", "_");
|
||||||
|
return normalized.toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ spring:
|
||||||
init:
|
init:
|
||||||
mode: always
|
mode: always
|
||||||
schema-locations: classpath:schema.sql
|
schema-locations: classpath:schema.sql
|
||||||
|
data-locations: classpath:data.sql
|
||||||
|
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
|
url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
|
||||||
|
|
@ -13,6 +14,7 @@ spring:
|
||||||
password: ${DB_PASSWD}
|
password: ${DB_PASSWD}
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
|
defer-datasource-initialization: true
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: update
|
ddl-auto: update
|
||||||
show-sql: true
|
show-sql: true
|
||||||
|
|
|
||||||
|
|
@ -18,3 +18,26 @@ INSERT INTO "server-manager".tab_users (
|
||||||
CURRENT_TIMESTAMP
|
CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "server-manager".tab_type_options (id, category, value)
|
||||||
|
VALUES
|
||||||
|
(101, 'SERVER_TYPE', 'PRODUCTION'),
|
||||||
|
(102, 'SERVER_TYPE', 'HOMOLOGATION'),
|
||||||
|
(103, 'SERVER_TYPE', 'DATABASE'),
|
||||||
|
(201, 'APPLICATION', 'ASTERISK'),
|
||||||
|
(202, 'APPLICATION', 'HITMANAGER'),
|
||||||
|
(203, 'APPLICATION', 'HITMANAGER_V2'),
|
||||||
|
(204, 'APPLICATION', 'OMNIHIT'),
|
||||||
|
(205, 'APPLICATION', 'HITPHONE'),
|
||||||
|
(206, 'APPLICATION', 'CDR'),
|
||||||
|
(207, 'APPLICATION', 'FUNCIONALIDADE'),
|
||||||
|
(208, 'APPLICATION', 'VOICEMAIL'),
|
||||||
|
(301, 'DATABASE', 'MYSQL'),
|
||||||
|
(302, 'DATABASE', 'POSTGRESQL'),
|
||||||
|
(303, 'DATABASE', 'SQLSERVER'),
|
||||||
|
(304, 'DATABASE', 'ORACLE'),
|
||||||
|
(305, 'DATABASE', 'REDIS'),
|
||||||
|
(306, 'DATABASE', 'MONGODB'),
|
||||||
|
(307, 'DATABASE', 'MARIADB'),
|
||||||
|
(308, 'DATABASE', 'NONE')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
|
||||||
|
|
@ -1 +1,3 @@
|
||||||
CREATE SCHEMA IF NOT EXISTS "server-manager";
|
CREATE SCHEMA IF NOT EXISTS "server-manager";
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: servers-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: servermanager
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: servers-backend
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_NAME: servermanager
|
||||||
|
DB_SCHEMA: server-manager
|
||||||
|
DB_USER: postgres
|
||||||
|
DB_PASSWD: postgres
|
||||||
|
JWT_SECRET: change-me-change-me-change-me-change-me-change-me
|
||||||
|
ports:
|
||||||
|
- "8081:8080"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_BACKEND_URL: http://localhost:8081
|
||||||
|
container_name: servers-frontend
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
PORT: 4173
|
||||||
|
ports:
|
||||||
|
- "4173:4173"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.turbo
|
||||||
|
.vite
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN corepack enable && yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
ARG VITE_BACKEND_URL=http://localhost:8080
|
||||||
|
ENV VITE_BACKEND_URL=$VITE_BACKEND_URL
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN npm install -g serve
|
||||||
|
ENV PORT=4173
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
EXPOSE 4173
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "serve -s dist -l ${PORT}"]
|
||||||
|
|
@ -2,6 +2,7 @@ import axios from "axios";
|
||||||
|
|
||||||
const baseURL = (import.meta.env.VITE_BACKEND_URL as string) || "http://localhost:8080";
|
const baseURL = (import.meta.env.VITE_BACKEND_URL as string) || "http://localhost:8080";
|
||||||
const REFRESH_ENDPOINT = "/api/auth/refresh";
|
const REFRESH_ENDPOINT = "/api/auth/refresh";
|
||||||
|
const LOGOUT_ENDPOINT = "/api/auth/logout";
|
||||||
const ACCESS_TOKEN_COOKIE = "access_token";
|
const ACCESS_TOKEN_COOKIE = "access_token";
|
||||||
const REFRESH_THRESHOLD_MS = 60_000;
|
const REFRESH_THRESHOLD_MS = 60_000;
|
||||||
|
|
||||||
|
|
@ -73,7 +74,19 @@ export const setAuthToken = (token?: string) => {
|
||||||
|
|
||||||
let refreshPromise: Promise<void> | null = null;
|
let refreshPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
const refreshAccessToken = async () => {
|
const clearAccessTokenCookie = () => {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.cookie = `${ACCESS_TOKEN_COOKIE}=; Max-Age=0; path=/`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAuthState = () => {
|
||||||
|
clearAccessTokenCookie();
|
||||||
|
setAuthToken(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const refreshAccessToken = async () => {
|
||||||
if (!refreshPromise) {
|
if (!refreshPromise) {
|
||||||
refreshPromise = api
|
refreshPromise = api
|
||||||
.post(REFRESH_ENDPOINT)
|
.post(REFRESH_ENDPOINT)
|
||||||
|
|
@ -92,6 +105,56 @@ const refreshAccessToken = async () => {
|
||||||
return refreshPromise;
|
return refreshPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureTokenFromRefresh = async () => {
|
||||||
|
await refreshAccessToken();
|
||||||
|
const updatedToken = getAccessToken();
|
||||||
|
if (!updatedToken) {
|
||||||
|
throw new Error("Token indisponível após atualização.");
|
||||||
|
}
|
||||||
|
setAuthToken(updatedToken);
|
||||||
|
return updatedToken;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateSession = async () => {
|
||||||
|
try {
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
await ensureTokenFromRefresh();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = decodeJwtPayload(token);
|
||||||
|
if (!payload?.sub) {
|
||||||
|
await ensureTokenFromRefresh();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.exp && payload.exp * 1000 <= Date.now()) {
|
||||||
|
await ensureTokenFromRefresh();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthToken(token);
|
||||||
|
|
||||||
|
if (isTokenExpiringSoon(token)) {
|
||||||
|
await ensureTokenFromRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
resetAuthState();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logout = async () => {
|
||||||
|
try {
|
||||||
|
await api.post(LOGOUT_ENDPOINT);
|
||||||
|
} finally {
|
||||||
|
resetAuthState();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
api.interceptors.request.use(async (config) => {
|
api.interceptors.request.use(async (config) => {
|
||||||
const isRefreshCall = config.url?.includes(REFRESH_ENDPOINT);
|
const isRefreshCall = config.url?.includes(REFRESH_ENDPOINT);
|
||||||
|
|
||||||
|
|
@ -120,7 +183,7 @@ api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error?.response?.status === 401) {
|
if (error?.response?.status === 401) {
|
||||||
setAuthToken(undefined);
|
resetAuthState();
|
||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
if (currentPath !== "/login") {
|
if (currentPath !== "/login") {
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { type ChangeEvent, type FormEvent, useEffect, useState } from "react";
|
import { type ChangeEvent, type FormEvent, useEffect, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import api from "../Api";
|
import api, { logout as requestLogout } from "../Api";
|
||||||
import { HeaderActions } from "./header/HeaderActions";
|
import { HeaderActions } from "./header/HeaderActions";
|
||||||
import { HeaderBrand } from "./header/HeaderBrand";
|
import { HeaderBrand } from "./header/HeaderBrand";
|
||||||
import { ProfileModal } from "./header/ProfileModal";
|
import { ProfileModal } from "./header/ProfileModal";
|
||||||
|
|
@ -10,6 +10,7 @@ import type { Applications, DatabaseType, ServersType } from "../types/enums";
|
||||||
import type { User } from "../types/User";
|
import type { User } from "../types/User";
|
||||||
import type { ProfileFormState, ServerFormState } from "./header/types";
|
import type { ProfileFormState, ServerFormState } from "./header/types";
|
||||||
import type { BulkImportResult } from "../types/BulkImport";
|
import type { BulkImportResult } from "../types/BulkImport";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
type ModalType = "addServer" | "editProfile" | "bulkImport" | null;
|
type ModalType = "addServer" | "editProfile" | "bulkImport" | null;
|
||||||
|
|
||||||
|
|
@ -18,6 +19,9 @@ interface HeaderProps {
|
||||||
userError: string | null;
|
userError: string | null;
|
||||||
onServerCreated?: () => Promise<void> | void;
|
onServerCreated?: () => Promise<void> | void;
|
||||||
onProfileUpdated?: (user: User) => void;
|
onProfileUpdated?: (user: User) => void;
|
||||||
|
serverTypeOptions?: ServersType[];
|
||||||
|
applicationOptions?: Applications[];
|
||||||
|
databaseOptions?: DatabaseType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultServerForm: ServerFormState = {
|
const defaultServerForm: ServerFormState = {
|
||||||
|
|
@ -38,11 +42,16 @@ const defaultProfileForm: ProfileFormState = {
|
||||||
password: "",
|
password: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const serverTypeOptions: ServersType[] = ["PRODUCTION", "HOMOLOGATION", "DATABASE"];
|
export const Header = ({
|
||||||
const applicationOptions: Applications[] = ["ASTERISK", "HITMANAGER", "HITMANAGER_V2", "OMNIHIT", "HITPHONE"];
|
currentUser,
|
||||||
const databaseOptions: DatabaseType[] = ["MYSQL", "POSTGRESQL", "SQLSERVER", "ORACLE", "REDIS", "MONGODB", "MARIADB", "NONE"];
|
userError,
|
||||||
|
onServerCreated,
|
||||||
export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdated }: HeaderProps) => {
|
onProfileUpdated,
|
||||||
|
serverTypeOptions = [],
|
||||||
|
applicationOptions = [],
|
||||||
|
databaseOptions = [],
|
||||||
|
}: HeaderProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [isMenuOpen, setMenuOpen] = useState(false);
|
const [isMenuOpen, setMenuOpen] = useState(false);
|
||||||
const [activeModal, setActiveModal] = useState<ModalType>(null);
|
const [activeModal, setActiveModal] = useState<ModalType>(null);
|
||||||
const [serverForm, setServerForm] = useState<ServerFormState>(defaultServerForm);
|
const [serverForm, setServerForm] = useState<ServerFormState>(defaultServerForm);
|
||||||
|
|
@ -178,6 +187,20 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
setActiveModal(null);
|
||||||
|
try {
|
||||||
|
await requestLogout();
|
||||||
|
toast.success("Sessão encerrada com sucesso.");
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.response?.data?.message || "Não foi possível encerrar a sessão.";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className={Styles.wrapper}>
|
<header className={Styles.wrapper}>
|
||||||
|
|
@ -188,6 +211,7 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
|
||||||
onAddServer={() => openModal("addServer")}
|
onAddServer={() => openModal("addServer")}
|
||||||
onEditProfile={() => openModal("editProfile")}
|
onEditProfile={() => openModal("editProfile")}
|
||||||
onBulkCreate={() => openModal("bulkImport")}
|
onBulkCreate={() => openModal("bulkImport")}
|
||||||
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import api from "../Api";
|
import api from "../Api";
|
||||||
import { ServerTypeLabels, type ServersType } from "../types/enums";
|
import { ServerTypeLabels } from "../types/enums";
|
||||||
import { Code, Database, FlaskConical, Server } from "lucide-icons-react";
|
import { Code, Database, FlaskConical, Server } from "lucide-icons-react";
|
||||||
|
|
||||||
interface ServerCount {
|
interface ServerCount {
|
||||||
type: ServersType;
|
type: string;
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -15,9 +15,9 @@ export const ServerCardMetrics = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCounts = async () => {
|
const fetchCounts = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get<Record<ServersType, number>>("/api/servers/type");
|
const { data } = await api.get<Record<string, number>>("/api/servers/type");
|
||||||
const normalized = Object.entries(data).map(([type, total]) => ({
|
const normalized = Object.entries(data).map(([type, total]) => ({
|
||||||
type: type as ServersType,
|
type,
|
||||||
total,
|
total,
|
||||||
}));
|
}));
|
||||||
setCounts(normalized);
|
setCounts(normalized);
|
||||||
|
|
@ -38,7 +38,7 @@ export const ServerCardMetrics = () => {
|
||||||
return <div className={Styles.placeholder}>Carregando métricas...</div>;
|
return <div className={Styles.placeholder}>Carregando métricas...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCardIcon = (type: ServersType) => {
|
const handleCardIcon = (type: string) => {
|
||||||
const iconProps = { size: 32, strokeWidth: 1.5 };
|
const iconProps = { size: 32, strokeWidth: 1.5 };
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
|
@ -64,7 +64,7 @@ export const ServerCardMetrics = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className={Styles.textGroup}>
|
<div className={Styles.textGroup}>
|
||||||
<p className={Styles.value}>{formatTotal(total)}</p>
|
<p className={Styles.value}>{formatTotal(total)}</p>
|
||||||
<p className={Styles.label}>{ServerTypeLabels[type]}</p>
|
<p className={Styles.label}>{ServerTypeLabels[type] ?? type}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import type { ChangeEvent } from "react";
|
||||||
|
import type { Applications, DatabaseType, ServersType } from "../types/enums";
|
||||||
|
|
||||||
|
type OptionAll = "ALL";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
search: string;
|
||||||
|
type: ServersType | OptionAll;
|
||||||
|
application: Applications | OptionAll;
|
||||||
|
dbType: DatabaseType | OptionAll;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onTypeChange: (value: ServersType | OptionAll) => void;
|
||||||
|
onApplicationChange: (value: Applications | OptionAll) => void;
|
||||||
|
onDbTypeChange: (value: DatabaseType | OptionAll) => void;
|
||||||
|
serverTypeOptions?: ServersType[];
|
||||||
|
applicationOptions?: Applications[];
|
||||||
|
databaseOptions?: DatabaseType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const withAllOption = <T extends string>(options?: T[]): Array<T | OptionAll> => {
|
||||||
|
if (!options || options.length === 0) {
|
||||||
|
return ["ALL"];
|
||||||
|
}
|
||||||
|
const unique = Array.from(new Set(options));
|
||||||
|
return ["ALL", ...unique];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ServersFilterBar = ({
|
||||||
|
search,
|
||||||
|
type,
|
||||||
|
application,
|
||||||
|
dbType,
|
||||||
|
onSearchChange,
|
||||||
|
onTypeChange,
|
||||||
|
onApplicationChange,
|
||||||
|
onDbTypeChange,
|
||||||
|
serverTypeOptions,
|
||||||
|
applicationOptions,
|
||||||
|
databaseOptions,
|
||||||
|
}: Props) => {
|
||||||
|
const typeOptions = withAllOption(serverTypeOptions);
|
||||||
|
const applicationOptionsList = withAllOption(applicationOptions);
|
||||||
|
const databaseOptionsList = withAllOption(databaseOptions);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={Styles.wrapper}>
|
||||||
|
<div className={Styles.searchGroup}>
|
||||||
|
<label htmlFor="server-search" className={Styles.label}>Buscar</label>
|
||||||
|
<input
|
||||||
|
id="server-search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nome, IP ou usuário..."
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => onSearchChange(event.target.value)}
|
||||||
|
className={Styles.input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
label="Tipo"
|
||||||
|
value={type}
|
||||||
|
onChange={(event) => onTypeChange(event.target.value as ServersType | OptionAll)}
|
||||||
|
options={typeOptions}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Aplicação"
|
||||||
|
value={application}
|
||||||
|
onChange={(event) => onApplicationChange(event.target.value as Applications | OptionAll)}
|
||||||
|
options={applicationOptionsList}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Banco"
|
||||||
|
value={dbType}
|
||||||
|
onChange={(event) => onDbTypeChange(event.target.value as DatabaseType | OptionAll)}
|
||||||
|
options={databaseOptionsList}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SelectProps<T extends string> {
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
onChange: (event: ChangeEvent<HTMLSelectElement>) => void;
|
||||||
|
options: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const Select = <T extends string>({ label, value, onChange, options }: SelectProps<T>) => {
|
||||||
|
return (
|
||||||
|
<div className={Styles.selectGroup}>
|
||||||
|
<label className={Styles.label}>{label}</label>
|
||||||
|
<select value={value} onChange={onChange} className={Styles.select}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option === "ALL" ? "Todos" : option.toLowerCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Styles = {
|
||||||
|
wrapper: "flex flex-wrap gap-4 rounded-lg border border-cardBorder bg-white/70 p-4 shadow-sm",
|
||||||
|
searchGroup: "flex-1 min-w-[220px]",
|
||||||
|
selectGroup: "flex min-w-[150px] flex-col gap-1",
|
||||||
|
label: "text-xs font-semibold uppercase tracking-wide text-text-secondary",
|
||||||
|
input: "w-full rounded-lg border border-cardBorder bg-white px-3 py-2 text-sm text-text outline-none focus:border-accent focus:ring-1 focus:ring-accent",
|
||||||
|
select: "w-full rounded-lg border border-cardBorder bg-white px-3 py-2 text-sm text-text outline-none focus:border-accent focus:ring-1 focus:ring-accent capitalize",
|
||||||
|
};
|
||||||
|
|
@ -1,58 +1,143 @@
|
||||||
|
import { useState } from "react";
|
||||||
import type { Server } from "../types/Server";
|
import type { Server } from "../types/Server";
|
||||||
|
|
||||||
interface Props {
|
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 [hideSensitive, setHideSensitive] = useState(false);
|
||||||
|
const showingFrom = totalItems === 0 ? 0 : page * pageSize + 1;
|
||||||
|
const showingTo = totalItems === 0 ? 0 : Math.min((page + 1) * pageSize, totalItems);
|
||||||
|
const hasResults = servers.length > 0;
|
||||||
|
|
||||||
|
const formatSensitiveValue = (value: string | number) => (hideSensitive ? "••••" : value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={Styles.card}>
|
<div className={Styles.card}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={Styles.hideButton}
|
||||||
|
onClick={() => setHideSensitive((prev) => !prev)}
|
||||||
|
disabled={loading || (!hasResults && !error)}
|
||||||
|
aria-pressed={hideSensitive}
|
||||||
|
>
|
||||||
|
{hideSensitive ? "Mostrar dados sensíveis" : "Ocultar dados sensíveis"}
|
||||||
|
</button>
|
||||||
{loading && <div className={Styles.status}>Carregando servidores...</div>}
|
{loading && <div className={Styles.status}>Carregando servidores...</div>}
|
||||||
{error && <div className={Styles.error}>{error}</div>}
|
{error && <div className={Styles.error}>{error}</div>}
|
||||||
{!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}>Tipo</th>
|
<th className={Styles.tableHeadCell}>Usuário</th>
|
||||||
<th className={Styles.tableHeadCell}>Aplicação</th>
|
<th className={Styles.tableHeadCell}>Senha</th>
|
||||||
<th className={Styles.tableHeadCell}>Banco</th>
|
<th className={Styles.tableHeadCell}>Tipo</th>
|
||||||
</tr>
|
<th className={Styles.tableHeadCell}>Aplicação</th>
|
||||||
</thead>
|
<th className={Styles.tableHeadCell}>Banco</th>
|
||||||
<tbody className={Styles.tableBody}>
|
</tr>
|
||||||
{servers.map((server) => (
|
</thead>
|
||||||
<tr
|
<tbody className={Styles.tableBody}>
|
||||||
key={server.id}
|
{servers.map((server) => (
|
||||||
className="hover:bg-gray-50 transition-colors even:bg-gray-50/50"
|
<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.name}</td>
|
||||||
<td className={Styles.rowCell}>{server.ip}</td>
|
<td className={Styles.rowCell}>{formatSensitiveValue(server.ip)}</td>
|
||||||
<td className={Styles.rowCell}>{server.port}</td>
|
<td className={Styles.rowCell}>{formatSensitiveValue(server.port)}</td>
|
||||||
<td className={Styles.rowCell}>{server.user}</td>
|
<td className={Styles.rowCell}>{formatSensitiveValue(server.user)}</td>
|
||||||
|
<td className={Styles.rowCell}>
|
||||||
|
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||||
|
{hideSensitive ? "••••" : server.password}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
<td className={`${Styles.rowCell} capitalize`}>{server.type.toLowerCase()}</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.application.toLowerCase()}</td>
|
||||||
<td className={`${Styles.rowCell} capitalize`}>{server.dbType.toLowerCase()}</td>
|
<td className={`${Styles.rowCell} capitalize`}>{server.dbType.toLowerCase()}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Styles = {
|
const Styles = {
|
||||||
card: "bg-card border border-cardBorder shadow-sm rounded-lg overflow-hidden",
|
card: "relative bg-card border border-cardBorder shadow-sm rounded-lg overflow-hidden",
|
||||||
status: "p-4 text-text-secondary text-sm",
|
status: "p-4 text-text-secondary text-sm",
|
||||||
error: "p-4 text-red-600 text-sm",
|
error: "p-4 text-red-600 text-sm",
|
||||||
tableWrapper: "overflow-x-auto rounded-lg shadow-sm border border-cardBorder",
|
tableWrapper: "overflow-x-auto rounded-lg shadow-sm border border-cardBorder",
|
||||||
|
|
@ -61,4 +146,13 @@ 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",
|
||||||
|
hideButton: "absolute right-3 top-3 rounded-md border border-cardBorder px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary hover:bg-bg disabled:opacity-50 disabled:hover:bg-transparent bg-white shadow-sm",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { useEffect, useId, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
bodyClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Modal = ({ isOpen, title, onClose, children, description, bodyClassName }: ModalProps) => {
|
||||||
|
const instanceId = useId();
|
||||||
|
const titleId = `${instanceId}-title`;
|
||||||
|
const descriptionId = description ? `${instanceId}-description` : undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyClasses = [Styles.body, bodyClassName].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={Styles.overlay} role="presentation">
|
||||||
|
<div
|
||||||
|
className={Styles.dialog}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-describedby={descriptionId}
|
||||||
|
>
|
||||||
|
<div className={Styles.header}>
|
||||||
|
<h2 id={titleId} className={Styles.title}>{title}</h2>
|
||||||
|
<button type="button" aria-label="Fechar modal" className={Styles.closeButton} onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<div id={descriptionId} className={Styles.description}>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={bodyClasses}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Styles = {
|
||||||
|
overlay: "fixed inset-0 z-40 flex items-center justify-center bg-black/40 backdrop-blur-sm px-4 !mt-0",
|
||||||
|
dialog: "w-full max-w-2xl rounded-2xl border border-cardBorder bg-card p-6 shadow-xl transform transition-all duration-200 animate-fade-up",
|
||||||
|
header: "flex items-start justify-between gap-4 pb-4 border-b border-cardBorder",
|
||||||
|
title: "text-lg font-semibold text-text",
|
||||||
|
closeButton: "text-2xl leading-none text-text-secondary hover:text-text",
|
||||||
|
description: "pt-3 text-sm text-text-secondary",
|
||||||
|
body: "pt-4",
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { ChangeEvent } from "react";
|
import type { ChangeEvent } from "react";
|
||||||
import type { BulkImportResult } from "../../types/BulkImport";
|
import type { BulkImportResult } from "../../types/BulkImport";
|
||||||
|
import { Modal } from "../common/Modal";
|
||||||
|
|
||||||
interface BulkImportModalProps {
|
interface BulkImportModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -24,91 +25,74 @@ export const BulkImportModal = ({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onDownloadTemplate,
|
onDownloadTemplate,
|
||||||
}: BulkImportModalProps) => {
|
}: BulkImportModalProps) => {
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const selected = event.target.files?.[0];
|
const selected = event.target.files?.[0];
|
||||||
onFileChange(selected ?? null);
|
onFileChange(selected ?? null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={Styles.modalOverlay} role="dialog" aria-modal="true">
|
<Modal isOpen={isOpen} title="Cadastro em massa" onClose={onClose} bodyClassName={Styles.modalBody}>
|
||||||
<div className={Styles.modal}>
|
<div className={Styles.uploadCard}>
|
||||||
<div className={Styles.modalHeader}>
|
<label htmlFor="bulk-file" className={Styles.dropLabel}>
|
||||||
<h2 className={Styles.modalTitle}>Cadastro em massa</h2>
|
<span className="text-base font-medium text-text">Selecionar arquivo CSV</span>
|
||||||
<button type="button" onClick={onClose} className={Styles.closeButton} aria-label="Fechar modal">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={Styles.modalBody}>
|
</div>
|
||||||
<div className={Styles.uploadCard}>
|
|
||||||
<label htmlFor="bulk-file" className={Styles.dropLabel}>
|
{error && <p className={Styles.errorText}>{error}</p>}
|
||||||
<span className="text-base font-medium text-text">Selecionar arquivo CSV</span>
|
|
||||||
<span className="text-xs text-text-secondary">
|
{result && (
|
||||||
Arraste e solte ou clique para procurar
|
<div className={Styles.resultCard}>
|
||||||
</span>
|
<div className={Styles.statsGrid}>
|
||||||
</label>
|
<Stat label="Processados" value={result.total} />
|
||||||
<input
|
<Stat label="Sucesso" value={result.succeeded} accent />
|
||||||
id="bulk-file"
|
<Stat label="Falhas" value={result.failed} danger />
|
||||||
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>
|
</div>
|
||||||
|
{result.failed > 0 && (
|
||||||
{error && <p className={Styles.errorText}>{error}</p>}
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-text">Detalhes das falhas:</p>
|
||||||
{result && (
|
<ul className={Styles.failureList}>
|
||||||
<div className={Styles.resultCard}>
|
{result.failures.map((failure) => (
|
||||||
<div className="flex flex-wrap gap-4">
|
<li key={failure.line}>
|
||||||
<Stat label="Processados" value={result.total} />
|
<span className="font-semibold">Linha {failure.line}</span>: {failure.error}
|
||||||
<Stat label="Sucesso" value={result.succeeded} accent />
|
</li>
|
||||||
<Stat label="Falhas" value={result.failed} danger />
|
))}
|
||||||
</div>
|
</ul>
|
||||||
{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>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -123,23 +107,14 @@ const Stat = ({
|
||||||
accent?: boolean;
|
accent?: boolean;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<div
|
<div className={`${Styles.statBase} ${accent ? Styles.statAccent : danger ? Styles.statDanger : Styles.statDefault}`}>
|
||||||
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-xs uppercase tracking-wide opacity-70">{label}</span>
|
||||||
<span className="text-2xl font-bold leading-tight">{value}</span>
|
<span className="text-2xl font-bold leading-tight">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const Styles = {
|
const Styles = {
|
||||||
modalOverlay: "fixed inset-0 z-40 flex items-center justify-center bg-black/40 backdrop-blur-sm px-4 !mt-0",
|
modalBody: "space-y-5",
|
||||||
modal: "w-full max-w-2xl rounded-2xl border border-cardBorder bg-card p-6 shadow-xl transform transition-all duration-200 animate-fade-up",
|
|
||||||
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",
|
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",
|
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",
|
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",
|
||||||
|
|
@ -149,5 +124,10 @@ const Styles = {
|
||||||
primaryButton: "rounded-md bg-accent px-4 py-2 text-sm font-semibold text-white hover:bg-hover disabled:opacity-70",
|
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",
|
errorText: "text-sm text-red-600",
|
||||||
resultCard: "rounded-xl border border-cardBorder bg-white/90 p-5 space-y-4",
|
resultCard: "rounded-xl border border-cardBorder bg-white/90 p-5 space-y-4",
|
||||||
|
statsGrid: "flex flex-wrap gap-4",
|
||||||
failureList: "list-disc pl-5 space-y-1 text-sm text-text-secondary max-h-40 overflow-auto",
|
failureList: "list-disc pl-5 space-y-1 text-sm text-text-secondary max-h-40 overflow-auto",
|
||||||
|
statBase: "flex flex-col rounded-lg border px-4 py-3 text-sm",
|
||||||
|
statDefault: "border-cardBorder bg-white text-text",
|
||||||
|
statAccent: "border-accent/40 bg-accent/10 text-accent",
|
||||||
|
statDanger: "border-red-200 bg-red-50 text-red-600",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { ChangeEvent, FormEvent } from "react";
|
import type { ChangeEvent, FormEvent } from "react";
|
||||||
import type { User } from "../../types/User";
|
import type { User } from "../../types/User";
|
||||||
import type { ProfileFormState } from "./types";
|
import type { ProfileFormState } from "./types";
|
||||||
|
import { Modal } from "../common/Modal";
|
||||||
|
|
||||||
interface ProfileModalProps {
|
interface ProfileModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -23,63 +24,50 @@ export const ProfileModal = ({
|
||||||
onChange,
|
onChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: ProfileModalProps) => {
|
}: ProfileModalProps) => {
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
const isDisabled = !currentUser;
|
const isDisabled = !currentUser;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={Styles.modalOverlay} role="dialog" aria-modal="true">
|
<Modal isOpen={isOpen} title="Editar perfil" onClose={onClose} bodyClassName={Styles.modalBody}>
|
||||||
<div className={Styles.modal}>
|
{userError ? (
|
||||||
<div className={Styles.modalHeader}>
|
<p className={Styles.errorMessage}>{userError}</p>
|
||||||
<h2 className={Styles.modalTitle}>Editar perfil</h2>
|
) : (
|
||||||
<button type="button" onClick={onClose} className={Styles.closeButton} aria-label="Fechar modal">
|
<form onSubmit={onSubmit} className={Styles.form}>
|
||||||
×
|
<div className={Styles.formGrid}>
|
||||||
</button>
|
<div className={Styles.field}>
|
||||||
</div>
|
<label htmlFor="firstName" className={Styles.label}>Nome</label>
|
||||||
{userError ? (
|
<input id="firstName" name="firstName" className={Styles.input} value={form.firstName} onChange={onChange} required disabled={isDisabled} />
|
||||||
<p className={Styles.helperText}>{userError}</p>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={onSubmit} className={Styles.form}>
|
|
||||||
<div className={Styles.formGrid}>
|
|
||||||
<div className={Styles.field}>
|
|
||||||
<label htmlFor="firstName" className={Styles.label}>Nome</label>
|
|
||||||
<input id="firstName" name="firstName" className={Styles.input} value={form.firstName} onChange={onChange} required disabled={isDisabled} />
|
|
||||||
</div>
|
|
||||||
<div className={Styles.field}>
|
|
||||||
<label htmlFor="lastName" className={Styles.label}>Sobrenome</label>
|
|
||||||
<input id="lastName" name="lastName" className={Styles.input} value={form.lastName} onChange={onChange} required disabled={isDisabled} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={Styles.field}>
|
<div className={Styles.field}>
|
||||||
<label htmlFor="email" className={Styles.label}>Email</label>
|
<label htmlFor="lastName" className={Styles.label}>Sobrenome</label>
|
||||||
<input id="email" name="email" type="email" className={Styles.input} value={form.email} onChange={onChange} required disabled={isDisabled} />
|
<input id="lastName" name="lastName" className={Styles.input} value={form.lastName} onChange={onChange} required disabled={isDisabled} />
|
||||||
</div>
|
</div>
|
||||||
<div className={Styles.field}>
|
</div>
|
||||||
<label htmlFor="password" className={Styles.label}>Nova senha</label>
|
<div className={Styles.field}>
|
||||||
<input id="password" name="password" type="password" className={Styles.input} value={form.password} onChange={onChange} placeholder="Informe uma nova senha" required disabled={isDisabled} />
|
<label htmlFor="email" className={Styles.label}>Email</label>
|
||||||
<p className={Styles.helperText}>Informe uma nova senha para confirmar a alteração.</p>
|
<input id="email" name="email" type="email" className={Styles.input} value={form.email} onChange={onChange} required disabled={isDisabled} />
|
||||||
</div>
|
</div>
|
||||||
<div className={Styles.modalActions}>
|
<div className={Styles.field}>
|
||||||
<button type="button" className={Styles.secondaryButton} onClick={onClose}>Cancelar</button>
|
<label htmlFor="password" className={Styles.label}>Nova senha</label>
|
||||||
<button type="submit" className={Styles.primaryButton} disabled={loading || isDisabled}>
|
<input id="password" name="password" type="password" className={Styles.input} value={form.password} onChange={onChange} placeholder="Informe uma nova senha" required disabled={isDisabled} />
|
||||||
{loading ? "Salvando..." : "Salvar alterações"}
|
<p className={Styles.helperText}>Informe uma nova senha para confirmar a alteração.</p>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
<div className={Styles.modalActions}>
|
||||||
</form>
|
<button type="button" className={Styles.secondaryButton} onClick={onClose}>Cancelar</button>
|
||||||
)}
|
<button type="submit" className={Styles.primaryButton} disabled={loading || isDisabled}>
|
||||||
</div>
|
{loading ? "Salvando..." : "Salvar alterações"}
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Styles = {
|
const Styles = {
|
||||||
modalOverlay: "fixed inset-0 z-40 flex items-center justify-center bg-black/40 backdrop-blur-sm px-4 !mt-0",
|
modalBody: "space-y-4",
|
||||||
modal: "w-full max-w-2xl rounded-2xl border border-cardBorder bg-card p-6 shadow-xl transform transition-all duration-200 animate-fade-up",
|
helperText: "text-sm text-text-secondary",
|
||||||
modalHeader: "flex items-start justify-between gap-4 pb-4 border-b border-cardBorder",
|
errorMessage: "text-sm text-red-600",
|
||||||
modalTitle: "text-lg font-semibold text-text",
|
form: "space-y-4",
|
||||||
closeButton: "text-2xl leading-none text-text-secondary hover:text-text",
|
|
||||||
helperText: "pt-4 text-sm text-text-secondary",
|
|
||||||
form: "pt-4 space-y-4",
|
|
||||||
formGrid: "grid gap-4 md:grid-cols-2",
|
formGrid: "grid gap-4 md:grid-cols-2",
|
||||||
field: "flex flex-col gap-2",
|
field: "flex flex-col gap-2",
|
||||||
label: "text-xs font-semibold uppercase tracking-wide text-text-secondary",
|
label: "text-xs font-semibold uppercase tracking-wide text-text-secondary",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { ChangeEvent, FormEvent } from "react";
|
import type { ChangeEvent, FormEvent } from "react";
|
||||||
import type { Applications, DatabaseType, ServersType } from "../../types/enums";
|
import type { Applications, DatabaseType, ServersType } from "../../types/enums";
|
||||||
import type { ServerFormState } from "./types";
|
import type { ServerFormState } from "./types";
|
||||||
|
import { Modal } from "../common/Modal";
|
||||||
|
|
||||||
interface ServerModalProps {
|
interface ServerModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -25,87 +26,73 @@ export const ServerModal = ({
|
||||||
applicationOptions,
|
applicationOptions,
|
||||||
databaseOptions,
|
databaseOptions,
|
||||||
}: ServerModalProps) => {
|
}: ServerModalProps) => {
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={Styles.modalOverlay} role="dialog" aria-modal="true">
|
<Modal isOpen={isOpen} title="Adicionar novo servidor" onClose={onClose} bodyClassName={Styles.modalBody}>
|
||||||
<div className={Styles.modal}>
|
<form onSubmit={onSubmit} className={Styles.form}>
|
||||||
<div className={Styles.modalHeader}>
|
<div className={Styles.formGrid}>
|
||||||
<h2 className={Styles.modalTitle}>Adicionar novo servidor</h2>
|
<div className={Styles.field}>
|
||||||
<button type="button" onClick={onClose} className={Styles.closeButton} aria-label="Fechar modal">
|
<label htmlFor="name" className={Styles.label}>Nome</label>
|
||||||
×
|
<input id="name" name="name" className={Styles.input} value={form.name} onChange={onChange} required />
|
||||||
</button>
|
</div>
|
||||||
|
<div className={Styles.field}>
|
||||||
|
<label htmlFor="ip" className={Styles.label}>IP</label>
|
||||||
|
<input id="ip" name="ip" className={Styles.input} value={form.ip} onChange={onChange} placeholder="192.168.0.10" required />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={onSubmit} className={Styles.form}>
|
<div className={Styles.formGrid}>
|
||||||
<div className={Styles.formGrid}>
|
<div className={Styles.field}>
|
||||||
<div className={Styles.field}>
|
<label htmlFor="port" className={Styles.label}>Porta</label>
|
||||||
<label htmlFor="name" className={Styles.label}>Nome</label>
|
<input id="port" name="port" type="number" min="1" className={Styles.input} value={form.port} onChange={onChange} required />
|
||||||
<input id="name" name="name" className={Styles.input} value={form.name} onChange={onChange} required />
|
|
||||||
</div>
|
|
||||||
<div className={Styles.field}>
|
|
||||||
<label htmlFor="ip" className={Styles.label}>IP</label>
|
|
||||||
<input id="ip" name="ip" className={Styles.input} value={form.ip} onChange={onChange} placeholder="192.168.0.10" required />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={Styles.formGrid}>
|
|
||||||
<div className={Styles.field}>
|
|
||||||
<label htmlFor="port" className={Styles.label}>Porta</label>
|
|
||||||
<input id="port" name="port" type="number" min="1" className={Styles.input} value={form.port} onChange={onChange} required />
|
|
||||||
</div>
|
|
||||||
<div className={Styles.field}>
|
|
||||||
<label htmlFor="user" className={Styles.label}>Usuário</label>
|
|
||||||
<input id="user" name="user" className={Styles.input} value={form.user} onChange={onChange} required />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={Styles.field}>
|
<div className={Styles.field}>
|
||||||
<label htmlFor="password" className={Styles.label}>Senha</label>
|
<label htmlFor="user" className={Styles.label}>Usuário</label>
|
||||||
<input id="password" name="password" type="password" className={Styles.input} value={form.password} onChange={onChange} required />
|
<input id="user" name="user" className={Styles.input} value={form.user} onChange={onChange} required />
|
||||||
</div>
|
|
||||||
<div className={Styles.formGrid}>
|
|
||||||
<div className={Styles.field}>
|
|
||||||
<label htmlFor="type" className={Styles.label}>Tipo</label>
|
|
||||||
<select id="type" name="type" className={Styles.select} value={form.type} onChange={onChange}>
|
|
||||||
{serverTypeOptions.map((option) => (
|
|
||||||
<option key={option} value={option}>{option.toLowerCase()}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className={Styles.field}>
|
|
||||||
<label htmlFor="application" className={Styles.label}>Aplicação</label>
|
|
||||||
<select id="application" name="application" className={Styles.select} value={form.application} onChange={onChange}>
|
|
||||||
{applicationOptions.map((option) => (
|
|
||||||
<option key={option} value={option}>{option.toLowerCase()}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={Styles.field}>
|
||||||
|
<label htmlFor="password" className={Styles.label}>Senha</label>
|
||||||
|
<input id="password" name="password" type="password" className={Styles.input} value={form.password} onChange={onChange} required />
|
||||||
|
</div>
|
||||||
|
<div className={Styles.formGrid}>
|
||||||
<div className={Styles.field}>
|
<div className={Styles.field}>
|
||||||
<label htmlFor="dbType" className={Styles.label}>Banco de dados</label>
|
<label htmlFor="type" className={Styles.label}>Tipo</label>
|
||||||
<select id="dbType" name="dbType" className={Styles.select} value={form.dbType} onChange={onChange}>
|
<select id="type" name="type" className={Styles.select} value={form.type} onChange={onChange}>
|
||||||
{databaseOptions.map((option) => (
|
{serverTypeOptions.map((option) => (
|
||||||
<option key={option} value={option}>{option.toLowerCase()}</option>
|
<option key={option} value={option}>{option.toLowerCase()}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className={Styles.modalActions}>
|
<div className={Styles.field}>
|
||||||
<button type="button" className={Styles.secondaryButton} onClick={onClose}>Cancelar</button>
|
<label htmlFor="application" className={Styles.label}>Aplicação</label>
|
||||||
<button type="submit" className={Styles.primaryButton} disabled={loading}>
|
<select id="application" name="application" className={Styles.select} value={form.application} onChange={onChange}>
|
||||||
{loading ? "Salvando..." : "Salvar servidor"}
|
{applicationOptions.map((option) => (
|
||||||
</button>
|
<option key={option} value={option}>{option.toLowerCase()}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
<div className={Styles.field}>
|
||||||
</div>
|
<label htmlFor="dbType" className={Styles.label}>Banco de dados</label>
|
||||||
|
<select id="dbType" name="dbType" className={Styles.select} value={form.dbType} onChange={onChange}>
|
||||||
|
{databaseOptions.map((option) => (
|
||||||
|
<option key={option} value={option}>{option.toLowerCase()}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className={Styles.modalActions}>
|
||||||
|
<button type="button" className={Styles.secondaryButton} onClick={onClose}>Cancelar</button>
|
||||||
|
<button type="submit" className={Styles.primaryButton} disabled={loading}>
|
||||||
|
{loading ? "Salvando..." : "Salvar servidor"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Styles = {
|
const Styles = {
|
||||||
modalOverlay: "fixed inset-0 z-40 flex items-center justify-center bg-black/40 backdrop-blur-sm px-4 !mt-0",
|
modalBody: "space-y-4",
|
||||||
modal: "w-full max-w-2xl rounded-2xl border border-cardBorder bg-card p-6 shadow-xl transform transition-all duration-200 animate-fade-up",
|
form: "space-y-4",
|
||||||
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",
|
|
||||||
form: "pt-4 space-y-4",
|
|
||||||
formGrid: "grid gap-4 md:grid-cols-2",
|
formGrid: "grid gap-4 md:grid-cols-2",
|
||||||
field: "flex flex-col gap-2",
|
field: "flex flex-col gap-2",
|
||||||
label: "text-xs font-semibold uppercase tracking-wide text-text-secondary",
|
label: "text-xs font-semibold uppercase tracking-wide text-text-secondary",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
import type { Applications, DatabaseType, ServersType } from "../../types/enums";
|
|
||||||
|
|
||||||
export type ServerFormState = {
|
export type ServerFormState = {
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
port: string;
|
port: string;
|
||||||
user: string;
|
user: string;
|
||||||
password: string;
|
password: string;
|
||||||
type: ServersType;
|
type: string;
|
||||||
application: Applications;
|
application: string;
|
||||||
dbType: DatabaseType;
|
dbType: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProfileFormState = {
|
export type ProfileFormState = {
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,65 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import api, { getCurrentUsername } from "../Api";
|
import api, { getCurrentUsername } from "../Api";
|
||||||
import { Layout } from "../components/Layout";
|
import { Layout } from "../components/Layout";
|
||||||
import { Header } from "../components/Header";
|
import { Header } from "../components/Header";
|
||||||
import { ServerCardMetrics } from "../components/ServerCardMetrics";
|
import { ServerCardMetrics } from "../components/ServerCardMetrics";
|
||||||
import { ServersTable } from "../components/ServersTable";
|
import { ServersTable } from "../components/ServersTable";
|
||||||
|
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 [servers, setServers] = useState<Server[]>([]);
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
const [userError, setUserError] = useState<string | null>(null);
|
const [userError, setUserError] = useState<string | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
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[]>([]);
|
||||||
|
|
||||||
const fetchServers = useCallback(async () => {
|
const fetchServers = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get<Server[]>("/api/servers");
|
const params = new URLSearchParams();
|
||||||
setServers(data);
|
const trimmedQuery = searchTerm.trim();
|
||||||
|
if (trimmedQuery.length > 0) {
|
||||||
|
params.set("query", trimmedQuery);
|
||||||
|
}
|
||||||
|
if (typeFilter !== "ALL" && typeFilter.trim().length > 0) {
|
||||||
|
params.set("type", typeFilter);
|
||||||
|
}
|
||||||
|
if (applicationFilter !== "ALL" && applicationFilter.trim().length > 0) {
|
||||||
|
params.set("application", applicationFilter);
|
||||||
|
}
|
||||||
|
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<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);
|
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.";
|
||||||
|
|
@ -27,13 +67,16 @@ export const Dashboard = () => {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [searchTerm, typeFilter, applicationFilter, dbFilter, page, pageSize]);
|
||||||
|
|
||||||
const fetchCurrentUser = useCallback(async () => {
|
const fetchCurrentUser = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const username = getCurrentUsername();
|
const username = getCurrentUsername();
|
||||||
if (!username) {
|
if (!username) {
|
||||||
setUserError("Não foi possível identificar o usuário logado.");
|
const message = "Não foi possível identificar o usuário logado.";
|
||||||
|
setUserError(message);
|
||||||
|
toast.error(message);
|
||||||
|
navigate("/login", { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { data } = await api.get<User>(`/api/users/username/${encodeURIComponent(username)}`);
|
const { data } = await api.get<User>(`/api/users/username/${encodeURIComponent(username)}`);
|
||||||
|
|
@ -44,12 +87,72 @@ export const Dashboard = () => {
|
||||||
setUserError(message);
|
setUserError(message);
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCurrentUser();
|
||||||
|
}, [fetchCurrentUser]);
|
||||||
|
|
||||||
|
const fetchTypeOptions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [typesResponse, applicationsResponse, databasesResponse] = await Promise.all([
|
||||||
|
api.get<string[]>("/api/type-options/SERVER_TYPE"),
|
||||||
|
api.get<string[]>("/api/type-options/APPLICATION"),
|
||||||
|
api.get<string[]>("/api/type-options/DATABASE"),
|
||||||
|
]);
|
||||||
|
setServerTypeOptions(typesResponse.data ?? []);
|
||||||
|
setApplicationOptions(applicationsResponse.data ?? []);
|
||||||
|
setDatabaseOptions(databasesResponse.data ?? []);
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.response?.data?.message || "Falha ao carregar opções padrão.";
|
||||||
|
toast.error(message);
|
||||||
|
setServerTypeOptions([]);
|
||||||
|
setApplicationOptions([]);
|
||||||
|
setDatabaseOptions([]);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchServers();
|
fetchServers();
|
||||||
fetchCurrentUser();
|
}, [fetchServers]);
|
||||||
}, [fetchServers, fetchCurrentUser]);
|
|
||||||
|
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]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className="h-screen py-10">
|
<Layout className="h-screen py-10">
|
||||||
|
|
@ -59,9 +162,35 @@ export const Dashboard = () => {
|
||||||
userError={userError}
|
userError={userError}
|
||||||
onServerCreated={fetchServers}
|
onServerCreated={fetchServers}
|
||||||
onProfileUpdated={setCurrentUser}
|
onProfileUpdated={setCurrentUser}
|
||||||
|
serverTypeOptions={serverTypeOptions}
|
||||||
|
applicationOptions={applicationOptions}
|
||||||
|
databaseOptions={databaseOptions}
|
||||||
/>
|
/>
|
||||||
<ServerCardMetrics />
|
<ServerCardMetrics />
|
||||||
<ServersTable servers={servers} loading={loading} error={error} />
|
<ServersFilterBar
|
||||||
|
search={searchTerm}
|
||||||
|
type={typeFilter}
|
||||||
|
application={applicationFilter}
|
||||||
|
dbType={dbFilter}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
onTypeChange={handleTypeChange}
|
||||||
|
onApplicationChange={handleApplicationChange}
|
||||||
|
onDbTypeChange={handleDbTypeChange}
|
||||||
|
serverTypeOptions={serverTypeOptions}
|
||||||
|
applicationOptions={applicationOptions}
|
||||||
|
databaseOptions={databaseOptions}
|
||||||
|
/>
|
||||||
|
<ServersTable
|
||||||
|
servers={servers}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={totalItems}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,45 @@
|
||||||
import type { ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { Navigate, useLocation } from "react-router-dom";
|
import { Navigate, useLocation } from "react-router-dom";
|
||||||
import { getAccessToken } from "../Api";
|
import { validateSession } from "../Api";
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
|
export const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
|
||||||
const token = getAccessToken();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [checkingAuth, setCheckingAuth] = useState(true);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
|
||||||
if (!token) {
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const verify = async () => {
|
||||||
|
try {
|
||||||
|
const valid = await validateSession();
|
||||||
|
if (active) {
|
||||||
|
setIsAuthenticated(valid);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (active) {
|
||||||
|
setCheckingAuth(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void verify();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (checkingAuth) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center text-text">
|
||||||
|
Validando sessão...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
content: T[];
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ export interface Server {
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
user: string;
|
user: string;
|
||||||
|
password: string;
|
||||||
type: ServersType;
|
type: ServersType;
|
||||||
application: Applications;
|
application: Applications;
|
||||||
dbType: DatabaseType;
|
dbType: DatabaseType;
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,9 @@
|
||||||
export type DatabaseType =
|
export type ServersType = string;
|
||||||
| 'MYSQL'
|
export type Applications = string;
|
||||||
| 'POSTGRESQL'
|
export type DatabaseType = string;
|
||||||
| 'SQLSERVER'
|
|
||||||
| 'ORACLE'
|
|
||||||
| 'REDIS'
|
|
||||||
| 'MONGODB'
|
|
||||||
| 'MARIADB'
|
|
||||||
| 'NONE';
|
|
||||||
|
|
||||||
export type Applications =
|
export const ServerTypeLabels: Record<string, string> = {
|
||||||
| 'ASTERISK'
|
PRODUCTION: "Produção",
|
||||||
| 'HITMANAGER'
|
HOMOLOGATION: "Homologação",
|
||||||
| 'HITMANAGER_V2'
|
DATABASE: "Banco de Dados",
|
||||||
| 'OMNIHIT'
|
|
||||||
| 'HITPHONE';
|
|
||||||
|
|
||||||
export type ServersType = 'PRODUCTION' | 'HOMOLOGATION' | 'DATABASE';
|
|
||||||
|
|
||||||
export const ServerTypeLabels: Record<ServersType, string> = {
|
|
||||||
PRODUCTION: "Produção",
|
|
||||||
HOMOLOGATION: "Homologação",
|
|
||||||
DATABASE: "Banco de Dados",
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue