Compare commits
9 Commits
af7511195d
...
a69aca5dc8
| Author | SHA1 | Date |
|---|---|---|
|
|
a69aca5dc8 | |
|
|
b6ba3b8593 | |
|
|
61b3af4c53 | |
|
|
2805440f9f | |
|
|
9f94cb08e8 | |
|
|
f73a6accb9 | |
|
|
7b8112d73c | |
|
|
75add469f7 | |
|
|
d48a2633d0 |
|
|
@ -12,6 +12,7 @@ Orientações rápidas para agentes ou automações que atuam neste repositório
|
||||||
- Em componentes React com Tailwind, mova classnames para uma constante `Styles` ao final do arquivo sempre que um elemento tiver mais de 5 classes (referência: `Login.tsx`).
|
- Em componentes React com Tailwind, mova classnames para uma constante `Styles` ao final do arquivo sempre que um elemento tiver mais de 5 classes (referência: `Login.tsx`).
|
||||||
- Em componentes React com Tailwind, mova classnames para uma constante `Styles` ao final do arquivo sempre que um elemento tiver mais de 5 classes (referência: `Login.tsx`).
|
- Em componentes React com Tailwind, mova classnames para uma constante `Styles` ao final do arquivo sempre que um elemento tiver mais de 5 classes (referência: `Login.tsx`).
|
||||||
- Quando houverem classnames que se repetem muitas vezes em vários elementos/componentes, mova esses grupos repetidos para a constante `Styles` mesmo que possuam menos de 5 classes — isso ajuda a evitar duplicação e facilita manutenção.
|
- Quando houverem classnames que se repetem muitas vezes em vários elementos/componentes, mova esses grupos repetidos para a constante `Styles` mesmo que possuam menos de 5 classes — isso ajuda a evitar duplicação e facilita manutenção.
|
||||||
|
- Sempre que uma view ou componente começar a crescer demais, quebre a interface em subcomponentes menores e reutilizáveis (ex: dividir headers em partes especializadas) antes de seguir evoluindo o layout.
|
||||||
|
|
||||||
## Padrão para mensagens de commit
|
## Padrão para mensagens de commit
|
||||||
**Instrução:** Gere um comando `git commit -m` completo, em português, seguindo o padrão **Conventional Commits**, com base no `diff` abaixo.
|
**Instrução:** Gere um comando `git commit -m` completo, em português, seguindo o padrão **Conventional Commits**, com base no `diff` abaixo.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import org.springframework.security.config.annotation.authentication.configurati
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
@ -44,7 +45,7 @@ public class SecurityConfig {
|
||||||
)
|
)
|
||||||
.authenticationProvider(authenticationProvider())
|
.authenticationProvider(authenticationProvider())
|
||||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
|
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
.httpBasic(Customizer.withDefaults());
|
.httpBasic(AbstractHttpConfigurer::disable);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.hitcommunications.servermanager.controllers;
|
package com.hitcommunications.servermanager.controllers;
|
||||||
|
|
||||||
|
import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse;
|
||||||
import com.hitcommunications.servermanager.model.dtos.NewServerDTO;
|
import com.hitcommunications.servermanager.model.dtos.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.enums.Applications;
|
||||||
|
|
@ -9,8 +10,10 @@ 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.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/servers")
|
@RequestMapping("/api/servers")
|
||||||
|
|
@ -39,6 +42,16 @@ public class ServersController {
|
||||||
return ResponseEntity.ok().body(serversService.getByType(type));
|
return ResponseEntity.ok().body(serversService.getByType(type));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/type")
|
||||||
|
public ResponseEntity<Map<ServersType, Integer>> countAllByType() {
|
||||||
|
return ResponseEntity.ok().body(serversService.countAllByType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/bulk")
|
||||||
|
public ResponseEntity<BulkServerImportResponse> bulkCreate(@RequestParam("file") MultipartFile file) {
|
||||||
|
return ResponseEntity.ok().body(serversService.bulkCreate(file));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/application/{application}")
|
@GetMapping("/application/{application}")
|
||||||
public ResponseEntity<List<ServerDTO>> getByApplication(@PathVariable Applications application) {
|
public ResponseEntity<List<ServerDTO>> getByApplication(@PathVariable Applications application) {
|
||||||
return ResponseEntity.ok().body(serversService.getByApplication(application));
|
return ResponseEntity.ok().body(serversService.getByApplication(application));
|
||||||
|
|
@ -60,4 +73,3 @@ public class ServersController {
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.hitcommunications.servermanager.model.dtos;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record BulkServerImportResponse(
|
||||||
|
int total,
|
||||||
|
int succeeded,
|
||||||
|
int failed,
|
||||||
|
List<FailedRow> failures
|
||||||
|
) {
|
||||||
|
public record FailedRow(
|
||||||
|
int line,
|
||||||
|
String error,
|
||||||
|
String raw
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,5 +13,6 @@ public interface ServersRepository extends JpaRepository<Servers, String> {
|
||||||
List<Servers> findByType(ServersType type);
|
List<Servers> findByType(ServersType type);
|
||||||
List<Servers> findByApplication(Applications application);
|
List<Servers> findByApplication(Applications application);
|
||||||
Optional<Servers> findByIpAndPort(String ip, Integer port);
|
Optional<Servers> findByIpAndPort(String ip, Integer port);
|
||||||
|
Integer countAllByType(ServersType type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,26 @@ package com.hitcommunications.servermanager.services;
|
||||||
|
|
||||||
import com.hitcommunications.servermanager.mappers.ServersMapper;
|
import com.hitcommunications.servermanager.mappers.ServersMapper;
|
||||||
import com.hitcommunications.servermanager.model.Servers;
|
import com.hitcommunications.servermanager.model.Servers;
|
||||||
|
import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse;
|
||||||
|
import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse.FailedRow;
|
||||||
import com.hitcommunications.servermanager.model.dtos.NewServerDTO;
|
import com.hitcommunications.servermanager.model.dtos.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.enums.Applications;
|
||||||
|
import com.hitcommunications.servermanager.model.enums.DatabaseType;
|
||||||
import com.hitcommunications.servermanager.model.enums.ServersType;
|
import com.hitcommunications.servermanager.model.enums.ServersType;
|
||||||
import com.hitcommunications.servermanager.repositories.ServersRepository;
|
import com.hitcommunications.servermanager.repositories.ServersRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
|
@ -48,6 +59,16 @@ public class ServersService {
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<ServersType, Integer> countAllByType() {
|
||||||
|
Map<ServersType, Integer> response = new HashMap<>();
|
||||||
|
|
||||||
|
for(ServersType type : ServersType.values()) {
|
||||||
|
response.put(type, repo.countAllByType(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
public List<ServerDTO> getByApplication(Applications application) {
|
public List<ServerDTO> getByApplication(Applications application) {
|
||||||
return repo.findByApplication(application)
|
return repo.findByApplication(application)
|
||||||
.stream()
|
.stream()
|
||||||
|
|
@ -55,6 +76,77 @@ public class ServersService {
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public BulkServerImportResponse bulkCreate(MultipartFile file) {
|
||||||
|
List<FailedRow> failures = new ArrayList<>();
|
||||||
|
int succeeded = 0;
|
||||||
|
int processed = 0;
|
||||||
|
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
int lineNumber = 0;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
lineNumber++;
|
||||||
|
if (line.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] columns = parseColumns(line);
|
||||||
|
if (isHeaderRow(columns)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
processed++;
|
||||||
|
try {
|
||||||
|
NewServerDTO dto = toNewServerDTO(columns);
|
||||||
|
create(dto);
|
||||||
|
succeeded++;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
failures.add(new FailedRow(lineNumber, ex.getMessage(), line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Erro ao ler arquivo CSV.", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
int failed = failures.size();
|
||||||
|
return new BulkServerImportResponse(processed, succeeded, failed, failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String[] parseColumns(String line) {
|
||||||
|
String[] rawColumns = line.split(";");
|
||||||
|
String[] columns = new String[rawColumns.length];
|
||||||
|
for (int i = 0; i < rawColumns.length; i++) {
|
||||||
|
columns[i] = rawColumns[i].trim();
|
||||||
|
}
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
private NewServerDTO toNewServerDTO(String[] columns) {
|
||||||
|
if (columns.length < 8) {
|
||||||
|
throw new IllegalArgumentException("Linha incompleta. Esperado 8 colunas.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String name = columns[0];
|
||||||
|
String ip = columns[1];
|
||||||
|
Integer port = Integer.parseInt(columns[2]);
|
||||||
|
String user = columns[3];
|
||||||
|
String password = columns[4];
|
||||||
|
ServersType type = ServersType.valueOf(columns[5].toUpperCase());
|
||||||
|
Applications application = Applications.valueOf(columns[6].toUpperCase());
|
||||||
|
DatabaseType dbType = DatabaseType.valueOf(columns[7].toUpperCase());
|
||||||
|
|
||||||
|
return new NewServerDTO(name, ip, port, user, password, type, application, dbType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isHeaderRow(String[] columns) {
|
||||||
|
if (columns.length < 8) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return "name".equalsIgnoreCase(columns[0]) &&
|
||||||
|
"ip".equalsIgnoreCase(columns[1]) &&
|
||||||
|
"port".equalsIgnoreCase(columns[2]);
|
||||||
|
}
|
||||||
|
|
||||||
public List<ServerDTO> getAll() {
|
public List<ServerDTO> getAll() {
|
||||||
return repo.findAll()
|
return repo.findAll()
|
||||||
.stream()
|
.stream()
|
||||||
|
|
@ -85,4 +177,3 @@ public class ServersService {
|
||||||
repo.deleteById(id);
|
repo.deleteById(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,133 @@
|
||||||
import axios from 'axios'
|
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 ACCESS_TOKEN_COOKIE = "access_token";
|
||||||
|
const REFRESH_THRESHOLD_MS = 60_000;
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL,
|
baseURL,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const setAuthToken = (token?: string) => {
|
export type JwtPayload = {
|
||||||
if (token) {
|
exp?: number;
|
||||||
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
sub?: string;
|
||||||
} else {
|
[key: string]: unknown;
|
||||||
delete api.defaults.headers.common['Authorization'];
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCookieValue = (name: string): string | undefined => {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookies = document.cookie?.split(";") ?? [];
|
||||||
|
const cookie = cookies
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.find((c) => c.startsWith(`${name}=`));
|
||||||
|
|
||||||
|
return cookie ? decodeURIComponent(cookie.split("=")[1]) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decodeJwtPayload = (token: string): JwtPayload | null => {
|
||||||
|
try {
|
||||||
|
const [, payload] = token.split(".");
|
||||||
|
if (!payload) return null;
|
||||||
|
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const decoded = typeof atob === "function" ? atob(base64) : "";
|
||||||
|
return decoded ? JSON.parse(decoded) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAccessToken = () => getCookieValue(ACCESS_TOKEN_COOKIE);
|
||||||
|
|
||||||
|
export const getCurrentUsername = () => {
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) return undefined;
|
||||||
|
const payload = decodeJwtPayload(token);
|
||||||
|
return typeof payload?.sub === "string" ? payload.sub : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTokenExpiringSoon = (token?: string) => {
|
||||||
|
if (!token) return false;
|
||||||
|
const payload = decodeJwtPayload(token);
|
||||||
|
if (!payload?.exp) return false;
|
||||||
|
|
||||||
|
const expiresAt = payload.exp * 1000;
|
||||||
|
const millisUntilExpiry = expiresAt - Date.now();
|
||||||
|
return millisUntilExpiry <= REFRESH_THRESHOLD_MS;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setAuthToken = (token?: string) => {
|
||||||
|
if (token) {
|
||||||
|
api.defaults.headers.common["Authorization"] = `Bearer ${token}`;
|
||||||
|
} else {
|
||||||
|
delete api.defaults.headers.common["Authorization"];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let refreshPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const refreshAccessToken = async () => {
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = api
|
||||||
|
.post(REFRESH_ENDPOINT)
|
||||||
|
.then(() => {
|
||||||
|
const token = getAccessToken();
|
||||||
|
setAuthToken(token);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
api.interceptors.request.use(async (config) => {
|
||||||
|
const isRefreshCall = config.url?.includes(REFRESH_ENDPOINT);
|
||||||
|
|
||||||
|
if (!isRefreshCall) {
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (isTokenExpiringSoon(token)) {
|
||||||
|
try {
|
||||||
|
await refreshAccessToken();
|
||||||
|
} catch (error) {
|
||||||
|
refreshPromise = null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedToken = getAccessToken();
|
||||||
|
if (updatedToken) {
|
||||||
|
config.headers = config.headers ?? {};
|
||||||
|
config.headers.Authorization = `Bearer ${updatedToken}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error?.response?.status === 401) {
|
||||||
|
setAuthToken(undefined);
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
if (currentPath !== "/login") {
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,18 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { Login } from './pages/Login';
|
import { Login } from './pages/Login';
|
||||||
import { Dashboard } from './pages/Dashboard';
|
import { Dashboard } from './pages/Dashboard';
|
||||||
|
import { ProtectedRoute } from './routes/ProtectedRoute';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Dashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
import { type ChangeEvent, type FormEvent, useEffect, useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import api from "../Api";
|
||||||
|
import { HeaderActions } from "./header/HeaderActions";
|
||||||
|
import { HeaderBrand } from "./header/HeaderBrand";
|
||||||
|
import { ProfileModal } from "./header/ProfileModal";
|
||||||
|
import { ServerModal } from "./header/ServerModal";
|
||||||
|
import { BulkImportModal } from "./header/BulkImportModal";
|
||||||
|
import type { Applications, DatabaseType, ServersType } from "../types/enums";
|
||||||
|
import type { User } from "../types/User";
|
||||||
|
import type { ProfileFormState, ServerFormState } from "./header/types";
|
||||||
|
import type { BulkImportResult } from "../types/BulkImport";
|
||||||
|
|
||||||
|
type ModalType = "addServer" | "editProfile" | "bulkImport" | null;
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
currentUser: User | null;
|
||||||
|
userError: string | null;
|
||||||
|
onServerCreated?: () => Promise<void> | void;
|
||||||
|
onProfileUpdated?: (user: User) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultServerForm: ServerFormState = {
|
||||||
|
name: "",
|
||||||
|
ip: "",
|
||||||
|
port: "",
|
||||||
|
user: "",
|
||||||
|
password: "",
|
||||||
|
type: "PRODUCTION",
|
||||||
|
application: "ASTERISK",
|
||||||
|
dbType: "MYSQL",
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultProfileForm: ProfileFormState = {
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverTypeOptions: ServersType[] = ["PRODUCTION", "HOMOLOGATION", "DATABASE"];
|
||||||
|
const applicationOptions: Applications[] = ["ASTERISK", "HITMANAGER", "HITMANAGER_V2", "OMNIHIT", "HITPHONE"];
|
||||||
|
const databaseOptions: DatabaseType[] = ["MYSQL", "POSTGRESQL", "SQLSERVER", "ORACLE", "REDIS", "MONGODB", "MARIADB", "NONE"];
|
||||||
|
|
||||||
|
export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdated }: HeaderProps) => {
|
||||||
|
const [isMenuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [activeModal, setActiveModal] = useState<ModalType>(null);
|
||||||
|
const [serverForm, setServerForm] = useState<ServerFormState>(defaultServerForm);
|
||||||
|
const [profileForm, setProfileForm] = useState<ProfileFormState>(defaultProfileForm);
|
||||||
|
const [serverLoading, setServerLoading] = useState(false);
|
||||||
|
const [profileLoading, setProfileLoading] = useState(false);
|
||||||
|
const [bulkFile, setBulkFile] = useState<File | null>(null);
|
||||||
|
const [bulkLoading, setBulkLoading] = useState(false);
|
||||||
|
const [bulkResult, setBulkResult] = useState<BulkImportResult | null>(null);
|
||||||
|
const [bulkError, setBulkError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentUser) {
|
||||||
|
setProfileForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
firstName: currentUser.firstName ?? "",
|
||||||
|
lastName: currentUser.lastName ?? "",
|
||||||
|
email: currentUser.email ?? "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
|
const toggleMenu = () => setMenuOpen((prev) => !prev);
|
||||||
|
const openModal = (modal: ModalType) => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
setActiveModal(modal);
|
||||||
|
};
|
||||||
|
const closeModal = () => {
|
||||||
|
setActiveModal(null);
|
||||||
|
setServerForm(defaultServerForm);
|
||||||
|
setProfileForm((prev) => ({ ...prev, password: "" }));
|
||||||
|
setBulkFile(null);
|
||||||
|
setBulkResult(null);
|
||||||
|
setBulkError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerFormChange = (event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = event.target;
|
||||||
|
setServerForm((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfileFormChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = event.target;
|
||||||
|
setProfileForm((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerSubmit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setServerLoading(true);
|
||||||
|
try {
|
||||||
|
await api.post("/api/servers", {
|
||||||
|
...serverForm,
|
||||||
|
port: Number(serverForm.port),
|
||||||
|
});
|
||||||
|
toast.success("Servidor criado com sucesso!");
|
||||||
|
setServerForm(defaultServerForm);
|
||||||
|
setActiveModal(null);
|
||||||
|
if (onServerCreated) {
|
||||||
|
await Promise.resolve(onServerCreated());
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.response?.data?.message || "Falha ao criar servidor.";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setServerLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfileSubmit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!currentUser) {
|
||||||
|
toast.error("Usuário não identificado.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProfileLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.put<User>(`/api/users/${currentUser.id}`, {
|
||||||
|
firstName: profileForm.firstName,
|
||||||
|
lastName: profileForm.lastName,
|
||||||
|
email: profileForm.email,
|
||||||
|
password: profileForm.password,
|
||||||
|
});
|
||||||
|
toast.success("Perfil atualizado com sucesso!");
|
||||||
|
setProfileForm((prev) => ({ ...prev, password: "" }));
|
||||||
|
setActiveModal(null);
|
||||||
|
onProfileUpdated?.(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.response?.data?.message || "Falha ao atualizar o perfil.";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setProfileLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkSubmit = async () => {
|
||||||
|
if (!bulkFile) {
|
||||||
|
toast.error("Selecione um arquivo CSV para importar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBulkLoading(true);
|
||||||
|
setBulkError(null);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", bulkFile);
|
||||||
|
const { data } = await api.post<BulkImportResult>("/api/servers/bulk", formData, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
setBulkResult(data);
|
||||||
|
toast.success(`Importação concluída: ${data.succeeded} sucesso(s).`);
|
||||||
|
await Promise.resolve(onServerCreated?.());
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.response?.data?.message || "Falha ao importar servidores.";
|
||||||
|
setBulkError(message);
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setBulkLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadTemplate = () => {
|
||||||
|
const sample = [
|
||||||
|
"name;ip;port;user;password;type;application;dbType",
|
||||||
|
"app-server;192.168.0.10;22;deploy;changeMe;PRODUCTION;HITMANAGER;POSTGRESQL",
|
||||||
|
].join("\n");
|
||||||
|
const blob = new Blob([sample], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "servers_template.csv";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className={Styles.wrapper}>
|
||||||
|
<HeaderBrand />
|
||||||
|
<HeaderActions
|
||||||
|
isMenuOpen={isMenuOpen}
|
||||||
|
onToggleMenu={toggleMenu}
|
||||||
|
onAddServer={() => openModal("addServer")}
|
||||||
|
onEditProfile={() => openModal("editProfile")}
|
||||||
|
onBulkCreate={() => openModal("bulkImport")}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ServerModal
|
||||||
|
isOpen={activeModal === "addServer"}
|
||||||
|
form={serverForm}
|
||||||
|
loading={serverLoading}
|
||||||
|
onClose={closeModal}
|
||||||
|
onChange={handleServerFormChange}
|
||||||
|
onSubmit={handleServerSubmit}
|
||||||
|
serverTypeOptions={serverTypeOptions}
|
||||||
|
applicationOptions={applicationOptions}
|
||||||
|
databaseOptions={databaseOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProfileModal
|
||||||
|
isOpen={activeModal === "editProfile"}
|
||||||
|
currentUser={currentUser}
|
||||||
|
userError={userError}
|
||||||
|
form={profileForm}
|
||||||
|
loading={profileLoading}
|
||||||
|
onClose={closeModal}
|
||||||
|
onChange={handleProfileFormChange}
|
||||||
|
onSubmit={handleProfileSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BulkImportModal
|
||||||
|
isOpen={activeModal === "bulkImport"}
|
||||||
|
file={bulkFile}
|
||||||
|
loading={bulkLoading}
|
||||||
|
result={bulkResult}
|
||||||
|
error={bulkError}
|
||||||
|
onClose={closeModal}
|
||||||
|
onFileChange={setBulkFile}
|
||||||
|
onSubmit={handleBulkSubmit}
|
||||||
|
onDownloadTemplate={handleDownloadTemplate}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Styles = {
|
||||||
|
wrapper: "flex items-center justify-between rounded-xl border border-cardBorder bg-card px-6 py-4 shadow-sm",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import api from "../Api";
|
||||||
|
import { ServerTypeLabels, type ServersType } from "../types/enums";
|
||||||
|
import { Code, Database, FlaskConical, Server } from "lucide-icons-react";
|
||||||
|
|
||||||
|
interface ServerCount {
|
||||||
|
type: ServersType;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerCardMetrics = () => {
|
||||||
|
const [counts, setCounts] = useState<ServerCount[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCounts = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<Record<ServersType, number>>("/api/servers/type");
|
||||||
|
const normalized = Object.entries(data).map(([type, total]) => ({
|
||||||
|
type: type as ServersType,
|
||||||
|
total,
|
||||||
|
}));
|
||||||
|
setCounts(normalized);
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.response?.data?.message || "Falha ao carregar o total de servidores.";
|
||||||
|
setError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCounts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className={Styles.error}>{error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counts.length === 0) {
|
||||||
|
return <div className={Styles.placeholder}>Carregando métricas...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCardIcon = (type: ServersType) => {
|
||||||
|
const iconProps = { size: 32, strokeWidth: 1.5 };
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "DATABASE":
|
||||||
|
return <Database {...iconProps} />;
|
||||||
|
case "HOMOLOGATION":
|
||||||
|
return <FlaskConical {...iconProps} />;
|
||||||
|
case "PRODUCTION":
|
||||||
|
return <Server {...iconProps} />;
|
||||||
|
default:
|
||||||
|
return <Code {...iconProps} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTotal = (value: number) => value.toLocaleString("pt-BR");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={Styles.grid}>
|
||||||
|
{counts.map(({ type, total }) => (
|
||||||
|
<div key={type} className={Styles.card}>
|
||||||
|
<div className={Styles.iconWrapper}>
|
||||||
|
{handleCardIcon(type)}
|
||||||
|
</div>
|
||||||
|
<div className={Styles.textGroup}>
|
||||||
|
<p className={Styles.value}>{formatTotal(total)}</p>
|
||||||
|
<p className={Styles.label}>{ServerTypeLabels[type]}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Styles = {
|
||||||
|
grid: "grid gap-4 sm:grid-cols-2 lg:grid-cols-3",
|
||||||
|
card: "flex items-center gap-4 rounded-xl border border-cardBorder bg-gradient-to-br from-white/90 to-card p-5 shadow-sm",
|
||||||
|
iconWrapper: "flex h-14 w-14 items-center justify-center rounded-lg border border-accent/20 bg-accent/10 text-accent",
|
||||||
|
textGroup: "flex flex-col",
|
||||||
|
label: "text-xs font-medium uppercase tracking-wide text-text-secondary",
|
||||||
|
value: "text-3xl font-semibold text-text leading-tight",
|
||||||
|
placeholder: "p-4 rounded-lg border border-cardBorder bg-card text-text-secondary text-sm",
|
||||||
|
error: "p-4 rounded-lg border border-red-200 bg-red-50 text-red-600 text-sm",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import type { Server } from "../types/Server";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
servers: Server[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServersTable = ({ servers, loading, error }: Props) => {
|
||||||
|
return (
|
||||||
|
<div className={Styles.card}>
|
||||||
|
{loading && <div className={Styles.status}>Carregando servidores...</div>}
|
||||||
|
{error && <div className={Styles.error}>{error}</div>}
|
||||||
|
{!loading && !error && servers.length === 0 && (
|
||||||
|
<div className={Styles.status}>Nenhum servidor encontrado.</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && servers.length > 0 && (
|
||||||
|
<div className={Styles.tableWrapper}>
|
||||||
|
<table className={Styles.table}>
|
||||||
|
<thead className={Styles.tableHead}>
|
||||||
|
<tr className="text-left">
|
||||||
|
<th className={Styles.tableHeadCell}>Nome</th>
|
||||||
|
<th className={Styles.tableHeadCell}>IP</th>
|
||||||
|
<th className={Styles.tableHeadCell}>Porta</th>
|
||||||
|
<th className={Styles.tableHeadCell}>Usuário</th>
|
||||||
|
<th className={Styles.tableHeadCell}>Tipo</th>
|
||||||
|
<th className={Styles.tableHeadCell}>Aplicação</th>
|
||||||
|
<th className={Styles.tableHeadCell}>Banco</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className={Styles.tableBody}>
|
||||||
|
{servers.map((server) => (
|
||||||
|
<tr
|
||||||
|
key={server.id}
|
||||||
|
className="hover:bg-gray-50 transition-colors even:bg-gray-50/50"
|
||||||
|
>
|
||||||
|
<td className={Styles.rowCell}>{server.name}</td>
|
||||||
|
<td className={Styles.rowCell}>{server.ip}</td>
|
||||||
|
<td className={Styles.rowCell}>{server.port}</td>
|
||||||
|
<td className={Styles.rowCell}>{server.user}</td>
|
||||||
|
<td className={`${Styles.rowCell} capitalize`}>{server.type.toLowerCase()}</td>
|
||||||
|
<td className={`${Styles.rowCell} capitalize`}>{server.application.toLowerCase()}</td>
|
||||||
|
<td className={`${Styles.rowCell} capitalize`}>{server.dbType.toLowerCase()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Styles = {
|
||||||
|
card: "bg-card border border-cardBorder shadow-sm rounded-lg overflow-hidden",
|
||||||
|
status: "p-4 text-text-secondary text-sm",
|
||||||
|
error: "p-4 text-red-600 text-sm",
|
||||||
|
tableWrapper: "overflow-x-auto rounded-lg shadow-sm border border-cardBorder",
|
||||||
|
table: "min-w-full divide-y divide-cardBorder table-auto",
|
||||||
|
tableHead: "bg-gray-50 sticky top-0",
|
||||||
|
tableHeadCell: "px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-text-secondary",
|
||||||
|
tableBody: "bg-white divide-y divide-cardBorder",
|
||||||
|
rowCell: "px-4 py-3 text-sm text-text",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
import type { ChangeEvent } from "react";
|
||||||
|
import type { BulkImportResult } from "../../types/BulkImport";
|
||||||
|
|
||||||
|
interface BulkImportModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
file: File | null;
|
||||||
|
loading: boolean;
|
||||||
|
result: BulkImportResult | null;
|
||||||
|
error: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onFileChange: (file: File | null) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onDownloadTemplate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BulkImportModal = ({
|
||||||
|
isOpen,
|
||||||
|
file,
|
||||||
|
loading,
|
||||||
|
result,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
onFileChange,
|
||||||
|
onSubmit,
|
||||||
|
onDownloadTemplate,
|
||||||
|
}: BulkImportModalProps) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selected = event.target.files?.[0];
|
||||||
|
onFileChange(selected ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={Styles.modalOverlay} role="dialog" aria-modal="true">
|
||||||
|
<div className={Styles.modal}>
|
||||||
|
<div className={Styles.modalHeader}>
|
||||||
|
<h2 className={Styles.modalTitle}>Cadastro em massa</h2>
|
||||||
|
<button type="button" onClick={onClose} className={Styles.closeButton} aria-label="Fechar modal">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={Styles.modalBody}>
|
||||||
|
<div className={Styles.uploadCard}>
|
||||||
|
<label htmlFor="bulk-file" className={Styles.dropLabel}>
|
||||||
|
<span className="text-base font-medium text-text">Selecionar arquivo CSV</span>
|
||||||
|
<span className="text-xs text-text-secondary">
|
||||||
|
Arraste e solte ou clique para procurar
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bulk-file"
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
<div className={Styles.dropzone} onClick={() => document.getElementById("bulk-file")?.click()}>
|
||||||
|
{file ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium text-text">{file.name}</p>
|
||||||
|
<p className="text-xs text-text-secondary">{(file.size / 1024).toFixed(1)} KB</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-text-secondary">Nenhum arquivo selecionado.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={Styles.helperText}>
|
||||||
|
Estrutura esperada: <code>name;ip;port;user;password;type;application;dbType</code>
|
||||||
|
</p>
|
||||||
|
<div className={Styles.actionsRow}>
|
||||||
|
<button type="button" className={Styles.secondaryButton} onClick={onDownloadTemplate}>
|
||||||
|
Baixar CSV de exemplo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={Styles.primaryButton}
|
||||||
|
disabled={!file || loading}
|
||||||
|
onClick={onSubmit}
|
||||||
|
>
|
||||||
|
{loading ? "Importando..." : "Importar arquivo"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className={Styles.errorText}>{error}</p>}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className={Styles.resultCard}>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<Stat label="Processados" value={result.total} />
|
||||||
|
<Stat label="Sucesso" value={result.succeeded} accent />
|
||||||
|
<Stat label="Falhas" value={result.failed} danger />
|
||||||
|
</div>
|
||||||
|
{result.failed > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-text">Detalhes das falhas:</p>
|
||||||
|
<ul className={Styles.failureList}>
|
||||||
|
{result.failures.map((failure) => (
|
||||||
|
<li key={failure.line}>
|
||||||
|
<span className="font-semibold">Linha {failure.line}</span>: {failure.error}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stat = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
accent,
|
||||||
|
danger,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
accent?: boolean;
|
||||||
|
danger?: boolean;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col rounded-lg border px-4 py-3 text-sm ${
|
||||||
|
accent ? "border-accent/40 bg-accent/10 text-accent" : danger ? "border-red-200 bg-red-50 text-red-600" : "border-cardBorder bg-white text-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-xs uppercase tracking-wide opacity-70">{label}</span>
|
||||||
|
<span className="text-2xl font-bold leading-tight">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Styles = {
|
||||||
|
modalOverlay: "fixed inset-0 z-40 flex items-center justify-center bg-black/40 backdrop-blur-sm px-4 !mt-0",
|
||||||
|
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",
|
||||||
|
dropLabel: "flex flex-col gap-1 text-center",
|
||||||
|
dropzone: "flex flex-col items-center justify-center rounded-lg border border-cardBorder bg-bg px-4 py-6 text-center cursor-pointer hover:border-accent hover:bg-white transition-colors",
|
||||||
|
helperText: "text-xs text-text-secondary",
|
||||||
|
actionsRow: "flex flex-wrap items-center gap-3",
|
||||||
|
secondaryButton: "rounded-md border border-cardBorder px-4 py-2 text-sm font-medium text-text hover:bg-bg",
|
||||||
|
primaryButton: "rounded-md bg-accent px-4 py-2 text-sm font-semibold text-white hover:bg-hover disabled:opacity-70",
|
||||||
|
errorText: "text-sm text-red-600",
|
||||||
|
resultCard: "rounded-xl border border-cardBorder bg-white/90 p-5 space-y-4",
|
||||||
|
failureList: "list-disc pl-5 space-y-1 text-sm text-text-secondary max-h-40 overflow-auto",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
export interface HeaderActionsProps {
|
||||||
|
isMenuOpen: boolean;
|
||||||
|
onToggleMenu: () => void;
|
||||||
|
onAddServer: () => void;
|
||||||
|
onEditProfile: () => void;
|
||||||
|
onBulkCreate: () => void;
|
||||||
|
onLogout?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HeaderActions = ({
|
||||||
|
isMenuOpen,
|
||||||
|
onToggleMenu,
|
||||||
|
onAddServer,
|
||||||
|
onEditProfile,
|
||||||
|
onBulkCreate,
|
||||||
|
onLogout,
|
||||||
|
}: HeaderActionsProps) => {
|
||||||
|
return (
|
||||||
|
<div className={Styles.actions}>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={Styles.menuTrigger}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isMenuOpen}
|
||||||
|
onClick={onToggleMenu}
|
||||||
|
>
|
||||||
|
Opções
|
||||||
|
</button>
|
||||||
|
{isMenuOpen && (
|
||||||
|
<div className={Styles.dropdown} role="menu">
|
||||||
|
<button type="button" className={Styles.dropdownItem} onClick={onAddServer}>
|
||||||
|
Adicionar servidor
|
||||||
|
</button>
|
||||||
|
<button type="button" className={Styles.dropdownItem} onClick={onBulkCreate}>
|
||||||
|
Cadastro em massa
|
||||||
|
</button>
|
||||||
|
<button type="button" className={Styles.dropdownItem} onClick={onEditProfile}>
|
||||||
|
Editar perfil
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className={Styles.logoutButton} onClick={onLogout}>
|
||||||
|
Sair
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Styles = {
|
||||||
|
actions: "flex items-center gap-3",
|
||||||
|
menuTrigger: "rounded-lg border border-cardBorder bg-white/70 px-4 py-2 text-sm font-medium text-text flex items-center gap-2 hover:bg-white transition-colors",
|
||||||
|
dropdown: "absolute right-0 mt-2 w-48 rounded-lg border border-cardBorder bg-white py-2 shadow-lg z-10",
|
||||||
|
dropdownItem: "w-full px-4 py-2 text-left text-sm text-text-secondary hover:bg-bg hover:text-text transition-colors",
|
||||||
|
logoutButton: "rounded-md bg-accent px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-hover",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
export const HeaderBrand = () => {
|
||||||
|
return (
|
||||||
|
<div className={Styles.brand}>
|
||||||
|
<img src="/logo.webp" alt="Logo Hit Communications" className={Styles.logo} />
|
||||||
|
<div>
|
||||||
|
<p className={Styles.title}>Hit Communications</p>
|
||||||
|
<p className={Styles.subtitle}>Servers Manager</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Styles = {
|
||||||
|
brand: "flex items-center gap-3",
|
||||||
|
logo: "h-10 w-10 object-contain",
|
||||||
|
title: "text-base font-semibold text-text",
|
||||||
|
subtitle: "text-xs uppercase tracking-wide text-text-secondary",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import type { ChangeEvent, FormEvent } from "react";
|
||||||
|
import type { User } from "../../types/User";
|
||||||
|
import type { ProfileFormState } from "./types";
|
||||||
|
|
||||||
|
interface ProfileModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
currentUser: User | null;
|
||||||
|
userError: string | null;
|
||||||
|
form: ProfileFormState;
|
||||||
|
loading: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onSubmit: (event: FormEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileModal = ({
|
||||||
|
isOpen,
|
||||||
|
currentUser,
|
||||||
|
userError,
|
||||||
|
form,
|
||||||
|
loading,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: ProfileModalProps) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const isDisabled = !currentUser;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={Styles.modalOverlay} role="dialog" aria-modal="true">
|
||||||
|
<div className={Styles.modal}>
|
||||||
|
<div className={Styles.modalHeader}>
|
||||||
|
<h2 className={Styles.modalTitle}>Editar perfil</h2>
|
||||||
|
<button type="button" onClick={onClose} className={Styles.closeButton} aria-label="Fechar modal">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{userError ? (
|
||||||
|
<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 className={Styles.field}>
|
||||||
|
<label htmlFor="email" className={Styles.label}>Email</label>
|
||||||
|
<input id="email" name="email" type="email" className={Styles.input} value={form.email} onChange={onChange} required disabled={isDisabled} />
|
||||||
|
</div>
|
||||||
|
<div className={Styles.field}>
|
||||||
|
<label htmlFor="password" className={Styles.label}>Nova senha</label>
|
||||||
|
<input id="password" name="password" type="password" className={Styles.input} value={form.password} onChange={onChange} placeholder="Informe uma nova senha" required disabled={isDisabled} />
|
||||||
|
<p className={Styles.helperText}>Informe uma nova senha para confirmar a alteração.</p>
|
||||||
|
</div>
|
||||||
|
<div className={Styles.modalActions}>
|
||||||
|
<button type="button" className={Styles.secondaryButton} onClick={onClose}>Cancelar</button>
|
||||||
|
<button type="submit" className={Styles.primaryButton} disabled={loading || isDisabled}>
|
||||||
|
{loading ? "Salvando..." : "Salvar alterações"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Styles = {
|
||||||
|
modalOverlay: "fixed inset-0 z-40 flex items-center justify-center bg-black/40 backdrop-blur-sm px-4 !mt-0",
|
||||||
|
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",
|
||||||
|
helperText: "pt-4 text-sm text-text-secondary",
|
||||||
|
form: "pt-4 space-y-4",
|
||||||
|
formGrid: "grid gap-4 md:grid-cols-2",
|
||||||
|
field: "flex flex-col gap-2",
|
||||||
|
label: "text-xs font-semibold uppercase tracking-wide text-text-secondary",
|
||||||
|
input: "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 disabled:opacity-70",
|
||||||
|
modalActions: "flex justify-end gap-3 pt-2",
|
||||||
|
secondaryButton: "rounded-md border border-cardBorder px-4 py-2 text-sm font-medium text-text hover:bg-bg disabled:opacity-50",
|
||||||
|
primaryButton: "rounded-md bg-accent px-4 py-2 text-sm font-semibold text-white hover:bg-hover disabled:opacity-70",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import type { ChangeEvent, FormEvent } from "react";
|
||||||
|
import type { Applications, DatabaseType, ServersType } from "../../types/enums";
|
||||||
|
import type { ServerFormState } from "./types";
|
||||||
|
|
||||||
|
interface ServerModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
form: ServerFormState;
|
||||||
|
loading: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChange: (event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => void;
|
||||||
|
onSubmit: (event: FormEvent) => void;
|
||||||
|
serverTypeOptions: ServersType[];
|
||||||
|
applicationOptions: Applications[];
|
||||||
|
databaseOptions: DatabaseType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerModal = ({
|
||||||
|
isOpen,
|
||||||
|
form,
|
||||||
|
loading,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
serverTypeOptions,
|
||||||
|
applicationOptions,
|
||||||
|
databaseOptions,
|
||||||
|
}: ServerModalProps) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={Styles.modalOverlay} role="dialog" aria-modal="true">
|
||||||
|
<div className={Styles.modal}>
|
||||||
|
<div className={Styles.modalHeader}>
|
||||||
|
<h2 className={Styles.modalTitle}>Adicionar novo servidor</h2>
|
||||||
|
<button type="button" onClick={onClose} className={Styles.closeButton} aria-label="Fechar modal">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={onSubmit} className={Styles.form}>
|
||||||
|
<div className={Styles.formGrid}>
|
||||||
|
<div className={Styles.field}>
|
||||||
|
<label htmlFor="name" className={Styles.label}>Nome</label>
|
||||||
|
<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 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}>
|
||||||
|
<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 className={Styles.field}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Styles = {
|
||||||
|
modalOverlay: "fixed inset-0 z-40 flex items-center justify-center bg-black/40 backdrop-blur-sm px-4 !mt-0",
|
||||||
|
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",
|
||||||
|
form: "pt-4 space-y-4",
|
||||||
|
formGrid: "grid gap-4 md:grid-cols-2",
|
||||||
|
field: "flex flex-col gap-2",
|
||||||
|
label: "text-xs font-semibold uppercase tracking-wide text-text-secondary",
|
||||||
|
input: "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: "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",
|
||||||
|
modalActions: "flex justify-end gap-3 pt-2",
|
||||||
|
secondaryButton: "rounded-md border border-cardBorder px-4 py-2 text-sm font-medium text-text hover:bg-bg disabled:opacity-50",
|
||||||
|
primaryButton: "rounded-md bg-accent px-4 py-2 text-sm font-semibold text-white hover:bg-hover disabled:opacity-70",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { Applications, DatabaseType, ServersType } from "../../types/enums";
|
||||||
|
|
||||||
|
export type ServerFormState = {
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: string;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
type: ServersType;
|
||||||
|
application: Applications;
|
||||||
|
dbType: DatabaseType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProfileFormState = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
@ -1,78 +1,68 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import api from "../Api";
|
import api, { getCurrentUsername } from "../Api";
|
||||||
import { Layout } from "../components/Layout";
|
import { Layout } from "../components/Layout";
|
||||||
|
import { Header } from "../components/Header";
|
||||||
|
import { ServerCardMetrics } from "../components/ServerCardMetrics";
|
||||||
|
import { ServersTable } from "../components/ServersTable";
|
||||||
import type { Server } from "../types/Server";
|
import type { Server } from "../types/Server";
|
||||||
|
import type { User } from "../types/User";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
export const Dashboard = () => {
|
export const Dashboard = () => {
|
||||||
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 [userError, setUserError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchServers = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<Server[]>("/api/servers");
|
||||||
|
setServers(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.response?.data?.message || "Falha ao carregar servidores.";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCurrentUser = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const username = getCurrentUsername();
|
||||||
|
if (!username) {
|
||||||
|
setUserError("Não foi possível identificar o usuário logado.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { data } = await api.get<User>(`/api/users/username/${encodeURIComponent(username)}`);
|
||||||
|
setCurrentUser(data);
|
||||||
|
setUserError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.response?.data?.message || "Falha ao carregar o perfil do usuário.";
|
||||||
|
setUserError(message);
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchServers = async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await api.get<Server[]>("/api/servers");
|
|
||||||
setServers(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
const message = err?.response?.data?.message || "Falha ao carregar servidores.";
|
|
||||||
setError(message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchServers();
|
fetchServers();
|
||||||
}, []);
|
fetchCurrentUser();
|
||||||
|
}, [fetchServers, fetchCurrentUser]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className="h-screen py-10">
|
<Layout className="h-screen py-10">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-semibold text-text">Servidores</h1>
|
<Header
|
||||||
|
currentUser={currentUser}
|
||||||
<div className={Styles.card}>
|
userError={userError}
|
||||||
{loading && <div className="p-4 text-text-secondary text-sm">Carregando servidores...</div>}
|
onServerCreated={fetchServers}
|
||||||
{error && <div className="p-4 text-red-600 text-sm">{error}</div>}
|
onProfileUpdated={setCurrentUser}
|
||||||
{!loading && !error && servers.length === 0 && (
|
/>
|
||||||
<div className="p-4 text-text-secondary text-sm">Nenhum servidor encontrado.</div>
|
<ServerCardMetrics />
|
||||||
)}
|
<ServersTable servers={servers} loading={loading} error={error} />
|
||||||
{!loading && !error && servers.length > 0 && (
|
|
||||||
<div className="overflow-x-auto rounded-lg shadow-sm border border-cardBorder">
|
|
||||||
<table className="min-w-full divide-y divide-cardBorder table-auto">
|
|
||||||
<thead className="bg-gray-50 sticky top-0">
|
|
||||||
<tr className="text-left">
|
|
||||||
<th className={Styles.tableHeadCell}>Nome</th>
|
|
||||||
<th className={Styles.tableHeadCell}>IP</th>
|
|
||||||
<th className={Styles.tableHeadCell}>Porta</th>
|
|
||||||
<th className={Styles.tableHeadCell}>Usuário</th>
|
|
||||||
<th className={Styles.tableHeadCell}>Tipo</th>
|
|
||||||
<th className={Styles.tableHeadCell}>Aplicação</th>
|
|
||||||
<th className={Styles.tableHeadCell}>Banco</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-cardBorder">
|
|
||||||
{servers.map((server) => (
|
|
||||||
<tr key={server.id} className="hover:bg-gray-50 transition-colors even:bg-gray-50/50">
|
|
||||||
<td className={Styles.rowCell}>{server.name}</td>
|
|
||||||
<td className={Styles.rowCell}>{server.ip}</td>
|
|
||||||
<td className={Styles.rowCell}>{server.port}</td>
|
|
||||||
<td className={Styles.rowCell}>{server.user}</td>
|
|
||||||
<td className={`${Styles.rowCell} capitalize`}>{server.type.toLowerCase()}</td>
|
|
||||||
<td className={`${Styles.rowCell} capitalize`}>{server.application.toLowerCase()}</td>
|
|
||||||
<td className={`${Styles.rowCell} capitalize`}>{server.dbType.toLowerCase()}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Styles = {
|
|
||||||
card: "bg-card border border-cardBorder shadow-sm rounded-lg overflow-hidden",
|
|
||||||
tableHeadCell: "px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-text-secondary",
|
|
||||||
rowCell: "px-4 py-3 text-sm text-text",
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Navigate, useLocation } from "react-router-dom";
|
||||||
|
import { getAccessToken } from "../Api";
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
|
||||||
|
const token = getAccessToken();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
export interface BulkImportFailure {
|
||||||
|
line: number;
|
||||||
|
error: string;
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkImportResult {
|
||||||
|
total: number;
|
||||||
|
succeeded: number;
|
||||||
|
failed: number;
|
||||||
|
failures: BulkImportFailure[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
lastLogin: string | null;
|
||||||
|
}
|
||||||
|
|
@ -16,3 +16,9 @@ export type Applications =
|
||||||
| 'HITPHONE';
|
| 'HITPHONE';
|
||||||
|
|
||||||
export type ServersType = 'PRODUCTION' | 'HOMOLOGATION' | 'DATABASE';
|
export type ServersType = 'PRODUCTION' | 'HOMOLOGATION' | 'DATABASE';
|
||||||
|
|
||||||
|
export const ServerTypeLabels: Record<ServersType, string> = {
|
||||||
|
PRODUCTION: "Produção",
|
||||||
|
HOMOLOGATION: "Homologação",
|
||||||
|
DATABASE: "Banco de Dados",
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export default {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
bg: '#FAFAF9',
|
bg: '#FAFAF9',
|
||||||
card: '#FFFFFF',
|
card: '#F4F4F2',
|
||||||
cardBorder: '#E5E7EB',
|
cardBorder: '#E5E7EB',
|
||||||
text: '#1A1A1A',
|
text: '#1A1A1A',
|
||||||
'text-secondary': '#6B7280',
|
'text-secondary': '#6B7280',
|
||||||
|
|
@ -28,4 +28,3 @@ export default {
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue