primer commit
This commit is contained in:
13
manager/Dockerfile
Normal file
13
manager/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine as build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
manager/index.html
Normal file
12
manager/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WhatsApp Gateway Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2590
manager/package-lock.json
generated
Normal file
2590
manager/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
manager/package.json
Normal file
28
manager/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "whatsapp-manager",
|
||||
"version": "1.0.0",
|
||||
"description": "WhatsApp Gateway Manager Web Interface",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"qrcode": "^1.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@vitejs/plugin-react": "^4.1.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^4.5.0",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31"
|
||||
}
|
||||
}
|
||||
6
manager/postcss.config.js
Normal file
6
manager/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
309
manager/src/components/ConnectionManager.tsx
Normal file
309
manager/src/components/ConnectionManager.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Modal } from './Modal';
|
||||
import { WebSocketService } from '../services/websocket';
|
||||
import { ConnectionStatus, MessageData, APIToken, RecentMessages } from '../types/gateway';
|
||||
|
||||
interface ConnectionManagerProps {
|
||||
wsService: WebSocketService;
|
||||
}
|
||||
|
||||
export const ConnectionManager: React.FC<ConnectionManagerProps> = ({ wsService }) => {
|
||||
const [qrCode, setQrCode] = useState<string>('');
|
||||
const [status, setStatus] = useState<ConnectionStatus['status']>('waiting');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [recentMessages, setRecentMessages] = useState<MessageData[]>([]);
|
||||
const [apiToken, setApiToken] = useState<APIToken | null>(null);
|
||||
const [showTokenModal, setShowTokenModal] = useState(false);
|
||||
const [loading, setLoading] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for QR codes
|
||||
wsService.on('qr', async (qrData: string) => {
|
||||
try {
|
||||
const qrCodeUrl = await QRCode.toDataURL(qrData, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
});
|
||||
setQrCode(qrCodeUrl);
|
||||
setStatus('qr');
|
||||
setError('');
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error);
|
||||
setError('Failed to generate QR code');
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for status updates
|
||||
wsService.on('status', (statusData: string) => {
|
||||
setStatus(statusData as ConnectionStatus['status']);
|
||||
if (statusData === 'connected') {
|
||||
setQrCode('');
|
||||
setError('');
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for errors
|
||||
wsService.on('error', (errorData: string) => {
|
||||
setError(errorData);
|
||||
setStatus('logout');
|
||||
});
|
||||
|
||||
// Listen for expired sessions
|
||||
wsService.on('expired', () => {
|
||||
setStatus('expired');
|
||||
setQrCode('');
|
||||
setError('La sesión ha expirado. Por favor, vuelve a escanear el código QR.');
|
||||
});
|
||||
|
||||
// Listen for new messages
|
||||
wsService.on('message', (messageData: MessageData) => {
|
||||
setRecentMessages(prev => [messageData, ...prev.slice(0, 9)]);
|
||||
});
|
||||
|
||||
// Listen for recent messages
|
||||
wsService.on('recent_messages', (messagesData: RecentMessages) => {
|
||||
if (messagesData.success && messagesData.data) {
|
||||
setRecentMessages(messagesData.data.messages);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for token generation
|
||||
wsService.on('token_generated', (tokenData: any) => {
|
||||
if (tokenData.success && tokenData.data) {
|
||||
setApiToken(tokenData.data);
|
||||
setShowTokenModal(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
wsService.off('qr', () => {});
|
||||
wsService.off('status', () => {});
|
||||
wsService.off('error', () => {});
|
||||
wsService.off('expired', () => {});
|
||||
wsService.off('message', () => {});
|
||||
wsService.off('recent_messages', () => {});
|
||||
wsService.off('token_generated', () => {});
|
||||
};
|
||||
}, [wsService]);
|
||||
|
||||
const handleRestartSession = () => {
|
||||
setLoading('restart');
|
||||
wsService.restartSession();
|
||||
setTimeout(() => setLoading(''), 2000);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setLoading('logout');
|
||||
wsService.logoutSession();
|
||||
setRecentMessages([]);
|
||||
setApiToken(null);
|
||||
setTimeout(() => setLoading(''), 2000);
|
||||
};
|
||||
|
||||
const handleGenerateToken = () => {
|
||||
setLoading('token');
|
||||
wsService.generateToken();
|
||||
setTimeout(() => setLoading(''), 2000);
|
||||
};
|
||||
|
||||
const handleCopyToken = async () => {
|
||||
if (apiToken?.token) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiToken.token);
|
||||
alert('Token copiado al portapapeles');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy token:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case 'connected': return 'text-green-600';
|
||||
case 'connecting':
|
||||
case 'reconnecting': return 'text-yellow-600';
|
||||
case 'qr': return 'text-blue-600';
|
||||
case 'expired': return 'text-orange-600';
|
||||
case 'logout': return 'text-red-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case 'waiting': return 'Esperando conexión...';
|
||||
case 'qr': return 'Escanea el código QR';
|
||||
case 'connecting': return 'Conectando...';
|
||||
case 'connected': return 'Conectado ✅';
|
||||
case 'reconnecting': return 'Reconectando...';
|
||||
case 'expired': return 'Sesión Expirada ⚠️';
|
||||
case 'logout': return 'Desconectado';
|
||||
default: return 'Estado desconocido';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case 'connected': return '✅';
|
||||
case 'expired': return '⚠️';
|
||||
case 'logout': return '❌';
|
||||
case 'qr': return '📱';
|
||||
case 'connecting':
|
||||
case 'reconnecting': return '🔄';
|
||||
default: return '⏳';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Panel Principal: QR y Estado */}
|
||||
<div className="flex-1">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-4xl mb-2">{getStatusIcon()}</div>
|
||||
<h2 className="text-2xl font-bold mb-2">WhatsApp Gateway</h2>
|
||||
<div className={`text-lg font-semibold ${getStatusColor()}`}>
|
||||
{getStatusText()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="w-full p-4 bg-red-50 border border-red-200 rounded-lg mb-6">
|
||||
<div className="text-red-800">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qrCode && (
|
||||
<div className="flex flex-col items-center space-y-4 mb-6">
|
||||
<div className="p-4 bg-white rounded-lg shadow-lg border">
|
||||
<img src={qrCode} alt="WhatsApp QR Code" className="w-64 h-64" />
|
||||
</div>
|
||||
<div className="text-center text-sm text-gray-600 max-w-md">
|
||||
<p>1. Abre WhatsApp en tu teléfono</p>
|
||||
<p>2. Ve a Configuración → Dispositivos vinculados</p>
|
||||
<p>3. Escanea este código QR</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'connected' && (
|
||||
<div className="text-center space-y-4 mb-6">
|
||||
<div className="text-green-600 text-6xl">✅</div>
|
||||
<p className="text-gray-700">Tu WhatsApp está conectado y listo para usar</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Botones de Acción */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6">
|
||||
<button
|
||||
onClick={handleRestartSession}
|
||||
disabled={loading === 'restart'}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-300 transition-colors"
|
||||
>
|
||||
{loading === 'restart' ? 'Reiniciando...' : 'Reiniciar Sesión'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
disabled={loading === 'logout' || status !== 'connected'}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-300 transition-colors"
|
||||
>
|
||||
{loading === 'logout' ? 'Cerrando...' : 'Logout'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGenerateToken}
|
||||
disabled={loading === 'token' || status !== 'connected'}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-300 transition-colors"
|
||||
>
|
||||
{loading === 'token' ? 'Generando...' : 'Generar Token'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Token Modal */}
|
||||
<Modal
|
||||
isOpen={showTokenModal}
|
||||
onClose={() => setShowTokenModal(false)}
|
||||
title="Token de API Generado"
|
||||
>
|
||||
{apiToken && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Este token te permitirá autenticarte en la API. Guárdalo en un lugar seguro ya que no podrás volver a verlo completo.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Token de Acceso</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 font-mono text-xs bg-gray-50 p-2 rounded border break-all">
|
||||
{apiToken.token}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopyToken}
|
||||
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors shrink-0"
|
||||
>
|
||||
Copiar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
Expira: {new Date(apiToken.expiresAt).toLocaleString()}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={() => setShowTokenModal(false)}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Panel Derecho: Mensajes Recientes */}
|
||||
<div className="lg:w-96">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">Mensajes Recientes</h3>
|
||||
<button
|
||||
onClick={() => wsService.getRecentMessages()}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{recentMessages.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
<div className="text-4xl mb-2">💬</div>
|
||||
<p>No hay mensajes recientes</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{recentMessages.map((message, index) => (
|
||||
<div key={`${message.id}-${index}`} className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div className="font-mono text-sm text-gray-600">{message.from}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(message.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-800">{message.content}</div>
|
||||
<div className="text-xs text-gray-500 mt-1 capitalize">{message.type}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
146
manager/src/components/Dashboard.tsx
Normal file
146
manager/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { WebSocketService } from '../services/websocket';
|
||||
import { ConnectionManager } from './ConnectionManager';
|
||||
import { GatewayStatus } from '../types/gateway';
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
const [wsService, setWsService] = useState<WebSocketService | null>(null);
|
||||
const [gatewayStatus, setGatewayStatus] = useState<GatewayStatus | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:3003';
|
||||
const service = new WebSocketService(wsUrl);
|
||||
setWsService(service);
|
||||
|
||||
service.connect()
|
||||
.then(() => {
|
||||
setIsConnected(true);
|
||||
fetchGatewayStatus();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to connect to WebSocket:', error);
|
||||
setIsConnected(false);
|
||||
});
|
||||
|
||||
// Fetch gateway status every 30 seconds (less frequent)
|
||||
const statusInterval = setInterval(fetchGatewayStatus, 30000);
|
||||
|
||||
return () => {
|
||||
service.disconnect();
|
||||
clearInterval(statusInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchGatewayStatus = async () => {
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
const response = await fetch(`${apiUrl}/api/status`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setGatewayStatus(data.data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch gateway status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatUptime = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${hours}h ${minutes}m ${secs}s`;
|
||||
};
|
||||
|
||||
if (!wsService) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Iniciando Manager...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900">WhatsApp Gateway Manager</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
isConnected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{isConnected ? 'Conectado' : 'Desconectado'}
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchGatewayStatus}
|
||||
className="px-3 py-1 bg-blue-600 text-white rounded-full text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="space-y-8">
|
||||
{/* Connection Manager - Panel Principal */}
|
||||
<ConnectionManager wsService={wsService} />
|
||||
|
||||
{/* Status Info - Panel Inferior Simplificado */}
|
||||
{gatewayStatus && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Estado del Sistema</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Estado Gateway</div>
|
||||
<div className="font-medium capitalize">{gatewayStatus.status}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Sesión</div>
|
||||
<div className="font-medium">{gatewayStatus.sessionId}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Tiempo Activo</div>
|
||||
<div className="font-medium">{formatUptime(gatewayStatus.uptime)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Memoria</div>
|
||||
<div className="font-medium">{(gatewayStatus.memory.heapUsed / 1024 / 1024).toFixed(2)} MB</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Reference - Panel API Simplificado */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Referencia Rápida API</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Envío de Mensajes</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="font-mono bg-gray-50 p-2 rounded">POST /api/send</div>
|
||||
<div className="font-mono bg-gray-50 p-2 rounded">POST /api/send/bulk</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Control de Sesión</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="font-mono bg-gray-50 p-2 rounded">POST /api/session/restart</div>
|
||||
<div className="font-mono bg-gray-50 p-2 rounded">POST /api/session/logout</div>
|
||||
<div className="font-mono bg-gray-50 p-2 rounded">POST /api/token</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
manager/src/components/Modal.tsx
Normal file
71
manager/src/components/Modal.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal Container */}
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
className="relative w-full max-w-lg transform rounded-lg bg-white p-6 text-left shadow-xl transition-all"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{title && (
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500 transition-colors"
|
||||
>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
16
manager/src/index.css
Normal file
16
manager/src/index.css
Normal file
@@ -0,0 +1,16 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
10
manager/src/main.tsx
Normal file
10
manager/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { Dashboard } from './components/Dashboard';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<Dashboard />
|
||||
</React.StrictMode>
|
||||
);
|
||||
126
manager/src/services/websocket.ts
Normal file
126
manager/src/services/websocket.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { WebSocketMessage } from '../types/gateway';
|
||||
|
||||
export class WebSocketService {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private reconnectDelay = 3000;
|
||||
private listeners: Map<string, ((data: any) => void)[]> = new Map();
|
||||
|
||||
constructor(private url: string) {}
|
||||
|
||||
connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
this.reconnectAttempts = 0;
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data);
|
||||
console.log('WebSocket message received:', message.type, message.data?.length || message.data);
|
||||
this.emit(message.type, message.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
this.handleReconnect();
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
reject(error);
|
||||
};
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleReconnect(): void {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect().catch(error => {
|
||||
console.error('Reconnect failed:', error);
|
||||
});
|
||||
}, this.reconnectDelay);
|
||||
} else {
|
||||
console.error('Max reconnect attempts reached');
|
||||
}
|
||||
}
|
||||
|
||||
on(event: string, callback: (data: any) => void): void {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, []);
|
||||
}
|
||||
this.listeners.get(event)!.push(callback);
|
||||
}
|
||||
|
||||
off(event: string, callback: (data: any) => void): void {
|
||||
const eventListeners = this.listeners.get(event);
|
||||
if (eventListeners) {
|
||||
const index = eventListeners.indexOf(callback);
|
||||
if (index > -1) {
|
||||
eventListeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emit(event: string, data: any): void {
|
||||
const eventListeners = this.listeners.get(event);
|
||||
if (eventListeners) {
|
||||
eventListeners.forEach(callback => callback(data));
|
||||
}
|
||||
}
|
||||
|
||||
// Send command to Gateway
|
||||
sendCommand(command: string, data?: any): void {
|
||||
if (this.isConnected()) {
|
||||
const message = {
|
||||
type: 'command',
|
||||
action: command,
|
||||
data: data || {}
|
||||
};
|
||||
this.ws!.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
// Gateway specific commands
|
||||
restartSession(): void {
|
||||
this.sendCommand('restart_session');
|
||||
}
|
||||
|
||||
logoutSession(): void {
|
||||
this.sendCommand('logout_session');
|
||||
}
|
||||
|
||||
getRecentMessages(): void {
|
||||
this.sendCommand('get_recent_messages');
|
||||
}
|
||||
|
||||
generateToken(): void {
|
||||
this.sendCommand('generate_token');
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
}
|
||||
44
manager/src/types/gateway.ts
Normal file
44
manager/src/types/gateway.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export interface ConnectionStatus {
|
||||
status: 'waiting' | 'qr' | 'connecting' | 'connected' | 'reconnecting' | 'logout' | 'expired';
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface QRData {
|
||||
qr: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface GatewayStatus {
|
||||
status: string;
|
||||
sessionId: string;
|
||||
uptime: number;
|
||||
memory: NodeJS.MemoryUsage;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: 'qr' | 'status' | 'error' | 'message' | 'expired';
|
||||
data: string | MessageData | any;
|
||||
}
|
||||
|
||||
export interface MessageData {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
type: 'text' | 'image' | 'document';
|
||||
}
|
||||
|
||||
export interface APIToken {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface RecentMessages {
|
||||
success: any;
|
||||
data: any;
|
||||
messages: MessageData[];
|
||||
total: number;
|
||||
}
|
||||
1
manager/src/vite-env.d.ts
vendored
Normal file
1
manager/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
11
manager/tailwind.config.js
Normal file
11
manager/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
21
manager/tsconfig.json
Normal file
21
manager/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
manager/tsconfig.node.json
Normal file
10
manager/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
14
manager/vite.config.ts
Normal file
14
manager/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3002,
|
||||
host: true
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user