feat: refatorar estrutura do projeto e adicionar funcionalidade de websocket

- Adiciona arquivo .env.example com variável VITE_URL_BACKEND
- Inclui .env no .gitignore
- Adiciona novas dependências: axios, pinia, socket.io-client,
- Remove sistema de roteamento anterior e simplifica App.vue
- Substitui componentes antigos (NavButton, ServerCard, SidePanel) por novo SessionCard
- Remove páginas antigas (Dashboard, Sections) e cria nova página Sessions
- Implementa composable useWebSocket para conexão com backend via socket.io
- Cria store Pinia para gerenciamento de estado das sessões e aplicações
- Define interfaces TypeScript para tipos de dados do websocket
- Atualiza mock de servidores para nova estrutura de dados
- Configura servidor Vite para hospedagem na porta 3333
- Ajusta configuração do TypeScript
master
Artur Oliveira 2025-08-21 09:46:26 -03:00
parent f80f277660
commit 4f31f091e6
20 changed files with 2105 additions and 1643 deletions

1
.env.example 100644
View File

@ -0,0 +1 @@
VITE_URL_BACKEND=http://seu-backend-url

3
.gitignore vendored
View File

@ -12,6 +12,9 @@ dist
dist-ssr
*.local
# Arquivos de ambiente
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json

View File

@ -9,7 +9,10 @@
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.11.0",
"lucide-vue-next": "^0.539.0",
"pinia": "^3.0.3",
"socket.io-client": "^4.8.1",
"vue": "^3.5.18",
"vue-router": "4"
},

View File

@ -1,22 +0,0 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import Dashboard from './src/pages/Dashboard.vue';
import Sections from './src/pages/Sections.vue';
const routes = [
{
path: '/',
name: 'dashboard',
component: Dashboard
},
{
path: '/sections',
name: 'Seções',
component: Sections
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
export default router;

View File

@ -1,14 +1,11 @@
<script setup lang="ts">
import SidePanel from './components/SidePanel.vue';
import Sessions from './pages/Sessions.vue';
</script>
<template>
<div class="flex">
<SidePanel />
<main class="flex-1">
<RouterView />
</main>
</div>
<div class="flex">
<main class="flex-1">
<Sessions />
</main>
</div>
</template>

View File

@ -1,16 +0,0 @@
<script setup lang="ts">
interface Props {
title: string;
isActive: boolean;
}
const props = defineProps<Props>();
</script>
<template>
<button :class="`text-2xl bg-bg p-2 w-full rounded-lg border-2 border-border
capitalize font-bold hover:bg-cardHover transition duration-150
${props.isActive ? 'text-accent' : ''}`">
{{ props.title }}
</button>
</template>

View File

@ -1,90 +0,0 @@
<script setup lang="ts">
import { Server } from 'lucide-vue-next';
import { computed } from 'vue';
import type { ServerData } from '../mocks/mockServersList';
const props = defineProps<ServerData>();
// Função para calcular a porcentagem de uso do armazenamento
const storagePercentage = computed(() => {
// Função para converter para GB
const convertToGB = (value: string): number => {
const num = parseFloat(value.replace(/[^\d.]/g, ''));
if (value.includes('TB')) {
return num * 1024; // Converte TB para GB
}
return num; // Já está em GB
};
const totalGB = convertToGB(props.totalStorage);
const usedGB = convertToGB(props.usedStorage);
return Math.round((usedGB / totalGB) * 100);
});
// Função para calcular a porcentagem de uso da RAM
const ramPercentage = computed(() => {
const total = parseFloat(props.totalRam.replace(/[^\d.]/g, ''));
const used = parseFloat(props.totalRamUsed.replace(/[^\d.]/g, ''));
return Math.round((used / total) * 100);
});
</script>
<template>
<div class="bg-cardBg text-text rounded-lg p-4">
<div class="flex flex-row items-center gap-4">
<div class="p-2 bg-bg rounded-md">
<Server :size="32" />
</div>
<div>
<h1 class="text-xl font-bold">{{ props.name }}</h1>
<h1 class="text-green-600">{{ props.ip }}</h1>
</div>
</div>
<div class="h-1 bg-bg my-4" />
<div>
<div class="flex flex-row justify-between text-lg">
<h1 class="font-bold">Ram total: </h1>
<h1>{{ props.totalRam }}</h1>
</div>
<!-- Barra de progresso da RAM (menor) -->
<div class="mt-1 mb-2">
<div class="flex justify-between text-md text-gray-400 mb-1">
<span>{{ props.totalRamUsed }}</span>
<span>{{ ramPercentage }}%</span>
</div>
<div class="w-full bg-bg rounded-full h-1.5">
<div class="h-1.5 rounded-full transition-all duration-300"
:class="ramPercentage > 80 ? 'bg-red-400' : ramPercentage > 60 ? 'bg-yellow-400' : 'bg-blue-400'"
:style="`width: ${ramPercentage}%`"></div>
</div>
<!-- Informações detalhadas da RAM -->
<div class="flex justify-between text-md text-gray-400 mt-1">
<span>Livre: {{ props.freeRam }}</span>
<span>Cache: {{ props.cachedRam }}</span>
</div>
</div>
<div class="flex flex-row justify-between text-lg">
<h1 class="font-bold">Armazenamento: </h1>
<h1>{{ props.totalStorage }}</h1>
</div>
<!-- Barra de progresso do armazenamento -->
<div class="mt-2 mb-3">
<div class="flex justify-between text-sm text-gray-400 mb-1">
<span>Usado: {{ props.usedStorage }}</span>
<span>{{ storagePercentage }}%</span>
</div>
<div class="w-full bg-bg rounded-full h-2">
<div class="h-2 rounded-full transition-all duration-300"
:class="storagePercentage > 80 ? 'bg-red-500' : storagePercentage > 60 ? 'bg-yellow-500' : 'bg-green-500'"
:style="`width: ${storagePercentage}%`"></div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,139 @@
<script setup lang="ts">
import { Server } from 'lucide-vue-next';
import { computed } from 'vue';
import type { Session } from '../props/websocketResponse';
interface Props {
session: Session;
index: number;
}
const props = defineProps<Props>();
const convertMemorySize = (size: string) => {
if (size.endsWith('Gi')) {
return size.replace('Gi', 'GB');
} else if (size.endsWith('Mi')) {
return size.replace('Mi', 'MB');
}
return size;
};
// Função para calcular a porcentagem de uso da RAM
const ramPercentage = computed(() => {
// Função para converter para Mi (Mebibytes)
const convertToMi = (value: string): number => {
const num = parseFloat(value.replace(/[^\d.]/g, ''));
if (value.includes('Gi')) {
return num * 1024; // Converte Gi para Mi
}
if (value.includes('Ti')) {
return num * 1024 * 1024; // Converte Ti para Mi
}
return num; // Já está em Mi
};
const totalMi = convertToMi(props.session.memoryTotal);
const usedMi = convertToMi(props.session.memoryUsed);
return Math.round((usedMi / totalMi) * 100);
});
// Função para converter a porcentagem de disco de string para número
const diskPercentageNumber = computed(() => {
const totalSize = parseFloat(props.session.diskSize.replace(/[^\d.]/g, ''));
const usedSize = parseFloat(props.session.diskUsed.replace(/[^\d.]/g, ''));
return Math.round((usedSize / totalSize) * 100);
});
</script>
<template>
<div class="bg-cardBg text-text rounded-lg p-4">
<div class="flex flex-row items-center gap-4">
<div class="p-2 bg-bg rounded-md">
<Server :size="32" />
</div>
<div>
<h1 class="text-xl font-bold">{{ session.name }}</h1>
<h1 class="text-green-600">{{ session.ipAddress }}</h1>
</div>
</div>
<div class="h-1 bg-bg my-4" />
<div>
<div class="flex flex-row justify-between text-lg">
<h1 class="font-bold">Ram total:</h1>
<h1>{{ convertMemorySize(session.memoryTotal) }}</h1>
</div>
<!-- Barra de progresso da RAM -->
<div class="mt-1 mb-2">
<div class="flex justify-between text-md text-gray-400 mb-1">
<span>{{ convertMemorySize(session.memoryUsed) }}</span>
<span>{{ ramPercentage }}%</span>
</div>
<div class="w-full bg-bg rounded-full h-1.5">
<div
class="h-1.5 rounded-full transition-all duration-300"
:class="
ramPercentage > 80 ? 'bg-red-400' : ramPercentage > 60 ? 'bg-yellow-400' : 'bg-blue-400'
"
:style="`width: ${ramPercentage}%`"
></div>
</div>
<!-- Informações detalhadas da RAM -->
<div class="flex justify-between text-md text-gray-400 mt-1">
<span>Livre: {{ convertMemorySize(session.memoryFree) }}</span>
<span>Cache: {{ convertMemorySize(session.memoryCache) }}</span>
</div>
</div>
<div class="flex flex-row justify-between text-lg">
<h1 class="font-bold">Armazenamento:</h1>
<h1>{{ session.diskSize }}</h1>
</div>
<!-- Barra de progresso do armazenamento -->
<div class="mt-2 mb-3">
<div class="flex justify-between text-sm text-gray-400 mb-1">
<span>Usado: {{ session.diskUsed }}</span>
<span>{{ diskPercentageNumber }}%</span>
</div>
<div class="w-full bg-bg rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-300"
:class="
diskPercentageNumber > 80
? 'bg-red-500'
: diskPercentageNumber > 60
? 'bg-yellow-500'
: 'bg-green-500'
"
:style="`width: ${diskPercentageNumber}%`"
></div>
</div>
<div class="flex justify-between text-sm text-gray-400 mt-1">
<span>Disponível: {{ session.diskAvailable }}</span>
</div>
</div>
<!-- Aplicações hospedadas -->
<div class="mt-4">
<h2 class="font-bold text-lg mb-2">Aplicações:</h2>
<div class="flex flex-wrap gap-1">
<span
v-if="session.appName && session.appName.length > 0"
v-for="app in session.appName"
:key="app"
class="px-2 py-1 bg-accent text-white text-xs rounded"
>
{{ app }}
</span>
<span v-else class="text-gray-400 text-sm">Nenhuma aplicação encontrada.</span>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,53 +0,0 @@
<script setup lang="ts">
import { ChevronLeft, ChevronRight, LayoutDashboard, Settings } from 'lucide-vue-next';
import { ref } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const isCollapsed = ref(false);
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value;
};
</script>
<template>
<div class="h-screen bg-cardBg border-r-2 border-border flex flex-col gap-10 transition-all duration-300"
:class="isCollapsed ? 'w-20 py-10 px-4' : 'w-fit p-10'">
<div class="flex items-center justify-between">
<h1 class="text-accent font-bold text-3xl transition-opacity duration-300"
:class="isCollapsed ? 'opacity-0 hidden' : 'opacity-100'">
Omnihit Manager
</h1>
<button @click="toggleCollapse"
class="p-2 rounded-lg hover:bg-bg transition-colors duration-200 text-accent">
<ChevronLeft v-if="!isCollapsed" :size="24" />
<ChevronRight v-else :size="24" />
</button>
</div>
<div class="flex flex-col gap-3">
<RouterLink to="/" class="block">
<div class="flex items-center gap-3 text-2xl bg-bg p-3 w-full rounded-lg border-2 border-border hover:bg-cardHover transition duration-150 cursor-pointer"
:class="route.path === '/' ? 'text-accent border-accent' : 'text-text'">
<LayoutDashboard :size="24" class="flex-shrink-0" />
<span class="capitalize font-bold transition-opacity duration-300"
:class="isCollapsed ? 'opacity-0 hidden' : 'opacity-100'">
Painel
</span>
</div>
</RouterLink>
<RouterLink to="/sections" class="block">
<div class="flex items-center gap-3 text-2xl bg-bg p-3 w-full rounded-lg border-2 border-border hover:bg-cardHover transition duration-150 cursor-pointer"
:class="route.path === '/sections' ? 'text-accent border-accent' : 'text-text'">
<Settings :size="24" class="flex-shrink-0" />
<span class="capitalize font-bold transition-opacity duration-300"
:class="isCollapsed ? 'opacity-0 hidden' : 'opacity-100'">
Seções
</span>
</div>
</RouterLink>
</div>
</div>
</template>

View File

@ -0,0 +1,84 @@
import { io, type Socket } from 'socket.io-client';
import { useStore } from '../stores/store';
import { onMounted, onUnmounted, ref } from 'vue';
import type { RefreshDataPayload } from '../props/websocketResponse';
export function useWebSocket() {
const store = useStore();
const socket = ref<Socket | null>(null);
const intervalIds = ref<{ applications: number[]; sessions: number[] }>({
applications: [],
sessions: [],
});
const clearIntervals = (type?: 'applications' | 'sessions') => {
if (type) {
intervalIds.value[type].forEach((id) => clearInterval(id));
intervalIds.value[type] = [];
} else {
Object.values(intervalIds.value)
.flat()
.forEach((id) => clearInterval(id));
intervalIds.value = { applications: [], sessions: [] };
}
};
const connectWebSocket = () => {
let sessionId = localStorage.getItem('sessionId');
socket.value = io(import.meta.env.VITE_URL_BACKEND, {
query: { sessionId },
});
socket.value.on('connect', () => {
if (!sessionId) {
sessionId = Math.random().toString(36).substring(2, 11);
localStorage.setItem('sessionId', sessionId);
socket.value?.emit('sessionId', sessionId);
}
});
socket.value.on('refreshData', (data: RefreshDataPayload) => {
clearIntervals();
store.setApplications(data.applications);
store.setSessions(data.sessions);
store.isLoading = false;
});
socket.value.on('error', () => {
alert(
'Atenção! Tentativa de conexão QRcode inválida. O dispositivo lido não corresponde com o telefone da aplicação.'
);
});
socket.value.on('disconnect', (reason) => {
console.log(`Desconectado: ${reason}`);
localStorage.removeItem('sessionId');
});
socket.value.on('reconnect', (attemptNumber) => {
console.log(`Reconectado após ${attemptNumber} tentativas.`);
});
};
const disconnectWebSocket = () => {
clearIntervals();
socket.value?.disconnect();
};
onMounted(() => {
connectWebSocket();
});
onUnmounted(() => {
disconnectWebSocket();
});
return {
socket,
intervalIds,
clearIntervals,
connectWebSocket,
disconnectWebSocket,
};
}

View File

@ -1,6 +1,10 @@
import { createApp } from 'vue'
import router from '../router'
import App from './App.vue'
import './style.css'
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
import { createPinia } from 'pinia';
createApp(App).use(router).mount('#app')
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.mount('#app');

View File

@ -1,58 +1,87 @@
export interface ServerData {
name: string;
ip: string;
totalRam: string;
totalRamUsed: string;
cachedRam: string;
freeRam: string;
totalStorage: string;
usedStorage: string;
freeStorage: string;
ipAddress: string;
memoryTotal: string;
memoryUsed: string;
memoryFree: string;
memoryCache: string;
diskSize: string;
diskUsed: string;
diskAvailable: string;
diskPercentage: string;
appName: string[];
caution: boolean;
}
export const mockServersList: ServerData[] = [
{
name: "Servidor Web 01",
ip: "192.168.1.10",
totalRam: "16GB",
totalRamUsed: "8.5GB",
cachedRam: "2.1GB",
freeRam: "7.5GB",
totalStorage: "500GB",
usedStorage: "320GB",
freeStorage: "180GB"
name: "SESSÃO 7",
ipAddress: "172.31.187.39",
memoryTotal: "2.0Gi",
memoryUsed: "100Mi",
memoryFree: "953Mi",
memoryCache: "993Mi",
diskSize: "20G",
diskUsed: "3.1G",
diskAvailable: "16G",
diskPercentage: "17%",
appName: [
"el_lojas",
"poc_colombia_test",
"polimix",
"same"
],
caution: false
},
{
name: "Servidor BD Principal",
ip: "192.168.1.11",
totalRam: "32GB",
totalRamUsed: "24.2GB",
cachedRam: "4.8GB",
freeRam: "7.8GB",
totalStorage: "1TB",
usedStorage: "750GB",
freeStorage: "250GB"
ipAddress: "192.168.1.11",
memoryTotal: "32Gi",
memoryUsed: "24.2Gi",
memoryFree: "7.8Gi",
memoryCache: "4.8Gi",
diskSize: "1T",
diskUsed: "750G",
diskAvailable: "250G",
diskPercentage: "75%",
appName: [
"database_primary",
"backup_service"
],
caution: true
},
{
name: "Servidor API",
ip: "192.168.1.12",
totalRam: "8GB",
totalRamUsed: "3.2GB",
cachedRam: "1.1GB",
freeRam: "4.8GB",
totalStorage: "250GB",
usedStorage: "120GB",
freeStorage: "130GB"
ipAddress: "192.168.1.12",
memoryTotal: "8Gi",
memoryUsed: "3.2Gi",
memoryFree: "4.8Gi",
memoryCache: "1.1Gi",
diskSize: "250G",
diskUsed: "120G",
diskAvailable: "130G",
diskPercentage: "48%",
appName: [
"api_gateway",
"auth_service"
],
caution: false
},
{
name: "Servidor Backup",
ip: "192.168.1.13",
totalRam: "16GB",
totalRamUsed: "5.1GB",
cachedRam: "2.5GB",
freeRam: "10.9GB",
totalStorage: "2TB",
usedStorage: "1.2TB",
freeStorage: "800GB"
ipAddress: "192.168.1.13",
memoryTotal: "16Gi",
memoryUsed: "5.1Gi",
memoryFree: "10.9Gi",
memoryCache: "2.5Gi",
diskSize: "2T",
diskUsed: "1.2T",
diskAvailable: "800G",
diskPercentage: "60%",
appName: [
"backup_manager",
"file_sync"
],
caution: false
}
];

View File

@ -1,14 +0,0 @@
<script setup lang="ts">
import ServerCard from '../components/ServerCard.vue';
import { mockServersList } from '../mocks/mockServersList';
const servers = mockServersList;
</script>
<template>
<div class="bg-bg h-screen text-text p-10">
<div class="grid grid-cols-4 gap-6">
<ServerCard v-for="server in servers" :key="server.ip" v-bind="server" />
</div>
</div>
</template>

View File

@ -1,6 +0,0 @@
<script setup lang="ts">
</script>
<template>
<h1 class="text-text">Section</h1>
</template>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useWebSocket } from '../composables/useWebSocket';
import SessionCard from '../components/SessionCard.vue';
import { useStore } from '../stores/store';
const store = useStore();
const { sessions, isLoading, reminderSessions } = storeToRefs(store);
useWebSocket();
// Compute se há alertas
const hasSessionAlerts = computed(() => reminderSessions.value);
</script>
<template>
<div class="bg-bg min-h-screen text-text">
<!-- Header de navegação -->
<div class="border-b border-border bg-cardBg">
<div class="p-6">
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<h1 class="text-2xl font-bold text-accent">SESSÕES</h1>
<div v-if="hasSessionAlerts" class="w-2 h-2 bg-red-500 rounded-full"></div>
</div>
</div>
</div>
</div>
<!-- Loading -->
<div v-if="isLoading" class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-accent"></div>
</div>
<!-- Conteúdo principal -->
<div v-else class="p-6">
<!-- Grid de sessões -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<SessionCard
v-for="(session, index) in sessions"
:key="`session_${index}`"
:session="session"
:index="index"
/>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,30 @@
export interface Application {
id: string;
name: string;
db: string;
status: string;
diskUse?: string;
diskSize?: string;
diskAvailable?: string;
url?: string;
qrcode?: string;
number?: string;
}
export interface Session {
name: string;
ipAddress: string;
memoryTotal: string;
memoryUsed: string;
memoryCache: string;
memoryFree: string;
diskSize: string;
diskUsed: string;
diskAvailable: string;
appName?: string[];
}
export interface RefreshDataPayload {
applications: Application[];
sessions: Session[];
}

View File

@ -0,0 +1,27 @@
import { defineStore } from 'pinia';
import type { Application, Session } from '../props/websocketResponse';
import { computed, reactive, ref } from 'vue';
export const useStore = defineStore('application', () => {
const applications = ref<Application[]>([]);
const sessions = ref<Session[]>([]);
const isLoading = ref(true);
const reminderSessions = ref(false);
const setApplications = (apps: Application[]) => {
applications.value = apps;
};
const setSessions = (sessionData: Session[]) => {
sessions.value = sessionData;
};
return {
applications,
sessions,
isLoading,
reminderSessions,
setApplications,
setSessions,
};
});

View File

@ -1,15 +1,15 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}

View File

@ -1,7 +1,11 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})
plugins: [vue()],
server: {
port: 3333,
host: '0.0.0.0',
},
});

3035
yarn.lock

File diff suppressed because it is too large Load Diff