fixed messaging!

This commit is contained in:
2025-05-28 14:06:36 +02:00
parent 2a1b6f8a22
commit 082924a3ba
11 changed files with 315 additions and 273 deletions

Binary file not shown.

5
app.py
View File

@@ -11,7 +11,7 @@ from routes.trash import trash_bp
from tasks import cleanup_trash from tasks import cleanup_trash
import click import click
from utils import timeago from utils import timeago
from extensions import db, login_manager, csrf, socketio from extensions import db, login_manager, csrf
# Load environment variables # Load environment variables
load_dotenv() load_dotenv()
@@ -31,7 +31,6 @@ def create_app():
login_manager.init_app(app) login_manager.init_app(app)
login_manager.login_view = 'auth.login' login_manager.login_view = 'auth.login'
csrf.init_app(app) csrf.init_app(app)
socketio.init_app(app)
@app.context_processor @app.context_processor
def inject_csrf_token(): def inject_csrf_token():
@@ -89,4 +88,4 @@ def profile_pic(filename):
return send_from_directory(os.path.join(os.getcwd(), 'uploads', 'profile_pics'), filename) return send_from_directory(os.path.join(os.getcwd(), 'uploads', 'profile_pics'), filename)
if __name__ == '__main__': if __name__ == '__main__':
socketio.run(app, debug=True) app.run(debug=True)

View File

@@ -1,4 +1,3 @@
from flask_socketio import SocketIO
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
@@ -6,5 +5,4 @@ from flask_wtf.csrf import CSRFProtect
# Initialize extensions # Initialize extensions
db = SQLAlchemy() db = SQLAlchemy()
login_manager = LoginManager() login_manager = LoginManager()
csrf = CSRFProtect() csrf = CSRFProtect()
socketio = SocketIO(cors_allowed_origins="*")

View File

@@ -9,5 +9,4 @@ WTForms==3.1.1
python-dotenv==1.0.1 python-dotenv==1.0.1
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
gunicorn==21.2.0 gunicorn==21.2.0
Flask-SocketIO==5.3.6
email_validator==2.1.0.post1 email_validator==2.1.0.post1

View File

@@ -1,13 +1,11 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_socketio import emit, join_room, leave_room
from models import db, Conversation, User, Message, MessageAttachment from models import db, Conversation, User, Message, MessageAttachment
from forms import ConversationForm from forms import ConversationForm
from routes.auth import require_password_change from routes.auth import require_password_change
import os import os
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from datetime import datetime from datetime import datetime
from extensions import socketio
conversations_bp = Blueprint('conversations', __name__, url_prefix='/conversations') conversations_bp = Blueprint('conversations', __name__, url_prefix='/conversations')
@@ -239,30 +237,45 @@ def delete_conversation(conversation_id):
flash('Conversation has been deleted successfully.', 'success') flash('Conversation has been deleted successfully.', 'success')
return redirect(url_for('conversations.conversations')) return redirect(url_for('conversations.conversations'))
@socketio.on('join_conversation') @conversations_bp.route('/<int:conversation_id>/messages', methods=['GET'])
@login_required @login_required
def on_join(data): @require_password_change
conversation_id = data.get('conversation_id') def get_messages(conversation_id):
conversation = Conversation.query.get_or_404(conversation_id) conversation = Conversation.query.get_or_404(conversation_id)
# Check if user is a member # Check if user is a member
if not current_user.is_admin and current_user not in conversation.members: if not current_user.is_admin and current_user not in conversation.members:
return return jsonify({'error': 'Unauthorized'}), 403
# Join the room # Get the last message ID from the request
join_room(f'conversation_{conversation_id}') last_message_id = request.args.get('last_message_id', type=int)
@socketio.on('leave_conversation') # Query for new messages
@login_required query = Message.query.filter_by(conversation_id=conversation_id)
def on_leave(data): if last_message_id:
conversation_id = data.get('conversation_id') query = query.filter(Message.id > last_message_id)
leave_room(f'conversation_{conversation_id}')
messages = query.order_by(Message.created_at.asc()).all()
@socketio.on('heartbeat')
@login_required # Format messages for response
def on_heartbeat(data): message_data = [{
# Just acknowledge the heartbeat to keep the connection alive 'id': message.id,
return {'status': 'ok'} 'content': message.content,
'created_at': message.created_at.strftime('%b %d, %Y %H:%M'),
'sender_id': str(message.user_id),
'sender_name': f"{message.user.username} {message.user.last_name}",
'sender_avatar': url_for('profile_pic', filename=message.user.profile_picture) if message.user.profile_picture else url_for('static', filename='default-avatar.png'),
'attachments': [{
'name': attachment.name,
'size': attachment.size,
'url': url_for('conversations.download_attachment', message_id=message.id, attachment_index=index)
} for index, attachment in enumerate(message.attachments)]
} for message in messages]
return jsonify({
'success': True,
'messages': message_data
})
@conversations_bp.route('/<int:conversation_id>/send_message', methods=['POST']) @conversations_bp.route('/<int:conversation_id>/send_message', methods=['POST'])
@login_required @login_required
@@ -272,59 +285,46 @@ def send_message(conversation_id):
# Check if user is a member # Check if user is a member
if not current_user.is_admin and current_user not in conversation.members: if not current_user.is_admin and current_user not in conversation.members:
return jsonify({'success': False, 'error': 'You do not have access to this conversation.'}), 403 return jsonify({'error': 'Unauthorized'}), 403
message_content = request.form.get('message', '').strip() message_content = request.form.get('message', '').strip()
file_count = int(request.form.get('file_count', 0)) file_count = int(request.form.get('file_count', 0))
if not message_content and file_count == 0: if not message_content and file_count == 0:
return jsonify({'success': False, 'error': 'Message or file is required.'}), 400 return jsonify({'error': 'Message cannot be empty'}), 400
# Create new message # Create the message
message = Message( message = Message(
content=message_content, content=message_content,
conversation_id=conversation_id, user_id=current_user.id,
user_id=current_user.id conversation_id=conversation_id
) )
db.session.add(message)
# Create conversation-specific directory db.session.flush() # Get the message ID
conversation_dir = os.path.join(UPLOAD_FOLDER, str(conversation_id))
os.makedirs(conversation_dir, exist_ok=True)
# Handle file attachments # Handle file attachments
attachments = [] attachments = []
for i in range(file_count): for i in range(file_count):
file = request.files.get(f'file_{i}') file_key = f'file_{i}'
if file and file.filename: if file_key in request.files:
if not allowed_file(file.filename): file = request.files[file_key]
return jsonify({'success': False, 'error': f'File type not allowed: {file.filename}'}), 400 if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
if file.content_length and file.content_length > MAX_FILE_SIZE: file_path = os.path.join(UPLOAD_FOLDER, filename)
return jsonify({'success': False, 'error': f'File size exceeds limit: {file.filename}'}), 400 file.save(file_path)
# Generate unique filename attachment = MessageAttachment(
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') message_id=message.id,
filename = secure_filename(file.filename) name=filename,
unique_filename = f"{timestamp}_{filename}" path=file_path,
file_path = os.path.join(conversation_dir, unique_filename) size=os.path.getsize(file_path)
)
# Save file db.session.add(attachment)
file.save(file_path) attachments.append(attachment)
# Create attachment record
attachment = MessageAttachment(
name=filename,
path=file_path,
type=get_file_extension(filename),
size=os.path.getsize(file_path)
)
message.attachments.append(attachment)
attachments.append(attachment)
db.session.add(message)
db.session.commit() db.session.commit()
# Prepare message data for WebSocket # Prepare message data for response
message_data = { message_data = {
'id': message.id, 'id': message.id,
'content': message.content, 'content': message.content,
@@ -339,10 +339,6 @@ def send_message(conversation_id):
} for index, attachment in enumerate(attachments)] } for index, attachment in enumerate(attachments)]
} }
# Emit the message to all users in the conversation room
socketio.emit('new_message', message_data, room=f'conversation_{conversation_id}')
# Return response with message data
return jsonify({ return jsonify({
'success': True, 'success': True,
'message': message_data 'message': message_data
@@ -369,43 +365,4 @@ def download_attachment(message_id, attachment_index):
) )
except (IndexError, Exception) as e: except (IndexError, Exception) as e:
flash('File not found.', 'error') flash('File not found.', 'error')
return redirect(url_for('conversations.conversation', conversation_id=conversation.id)) return redirect(url_for('conversations.conversation', conversation_id=conversation.id))
@conversations_bp.route('/<int:conversation_id>/messages')
@login_required
@require_password_change
def get_messages(conversation_id):
conversation = Conversation.query.get_or_404(conversation_id)
# Check if user is a member
if not current_user.is_admin and current_user not in conversation.members:
return jsonify({'success': False, 'error': 'You do not have access to this conversation.'}), 403
# Get the last message ID from the request
last_message_id = request.args.get('last_message_id', type=int)
# Query for new messages
query = Message.query.filter_by(conversation_id=conversation_id)
if last_message_id:
query = query.filter(Message.id > last_message_id)
messages = query.order_by(Message.created_at.asc()).all()
# Format messages for response
formatted_messages = []
for message in messages:
formatted_messages.append({
'id': message.id,
'content': message.content,
'created_at': message.created_at.strftime('%b %d, %Y %H:%M'),
'sender_id': str(message.user.id),
'sender_name': f"{message.user.username} {message.user.last_name}",
'sender_avatar': url_for('profile_pic', filename=message.user.profile_picture) if message.user.profile_picture else url_for('static', filename='default-avatar.png'),
'attachments': [{
'name': attachment.name,
'size': attachment.size,
'url': url_for('conversations.download_attachment', message_id=message.id, attachment_index=index)
} for index, attachment in enumerate(message.attachments)]
})
return jsonify({'success': True, 'messages': formatted_messages})

View File

@@ -1,127 +1,45 @@
// Global state and socket management // Global state and polling management
if (typeof window.ChatManager === 'undefined') { if (typeof window.ChatManager === 'undefined') {
window.ChatManager = (function() { window.ChatManager = (function() {
let instance = null; let instance = null;
let socket = null; let pollInterval = null;
const state = { const state = {
addedMessageIds: new Set(), addedMessageIds: new Set(),
messageQueue: new Set(), messageQueue: new Set(),
connectionState: { connectionState: {
hasJoined: false, hasJoined: false,
isConnected: false, isConnected: true,
lastMessageId: null, lastMessageId: null,
connectionAttempts: 0, pollAttempts: 0
socketId: null
} }
}; };
function init(conversationId) { function init(conversationId) {
if (instance) { if (instance) {
console.log('[ChatManager] Instance already exists, returning existing instance');
return instance; return instance;
} }
console.log('[ChatManager] Initializing new instance for conversation:', conversationId);
// Initialize message IDs from existing messages // Initialize message IDs from existing messages
$('.message').each(function() { $('.message').each(function() {
const messageId = $(this).data('message-id'); const messageId = $(this).data('message-id');
if (messageId) { if (messageId) {
state.addedMessageIds.add(messageId); state.addedMessageIds.add(messageId);
} state.connectionState.lastMessageId = Math.max(state.connectionState.lastMessageId || 0, messageId);
}); console.log('[ChatManager] Initialized with existing message:', {
messageId: messageId,
// Create socket instance lastMessageId: state.connectionState.lastMessageId
socket = io(window.location.origin, {
path: '/socket.io/',
transports: ['polling', 'websocket'],
upgrade: true,
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
timeout: 20000,
autoConnect: true,
forceNew: true,
multiplex: false,
pingTimeout: 60000,
pingInterval: 25000,
upgradeTimeout: 10000,
rememberUpgrade: true,
rejectUnauthorized: false,
extraHeaders: {
'X-Forwarded-Proto': 'https'
}
});
// Set up socket event handlers
socket.on('connect', function() {
state.connectionState.isConnected = true;
state.connectionState.connectionAttempts++;
state.connectionState.socketId = socket.id;
console.log('Socket connected:', {
attempt: state.connectionState.connectionAttempts,
socketId: socket.id,
existingSocketId: state.connectionState.socketId,
transport: socket.io.engine.transport.name
});
// Always rejoin the room on connect
console.log('Joining conversation room:', conversationId);
socket.emit('join_conversation', {
conversation_id: conversationId,
timestamp: new Date().toISOString(),
socketId: socket.id
});
state.connectionState.hasJoined = true;
});
socket.on('disconnect', function(reason) {
console.log('Disconnected from conversation:', {
reason: reason,
socketId: socket.id,
connectionState: state.connectionState,
transport: socket.io.engine?.transport?.name
});
state.connectionState.isConnected = false;
state.connectionState.socketId = null;
});
socket.on('connect_error', function(error) {
console.error('Connection error:', error);
// Try to reconnect with polling if websocket fails
if (socket.io.engine?.transport?.name === 'websocket') {
console.log('WebSocket failed, falling back to polling');
socket.io.opts.transports = ['polling'];
}
});
socket.on('error', function(error) {
console.error('Socket error:', error);
});
// Add heartbeat to keep connection alive
setInterval(function() {
if (socket.connected) {
socket.emit('heartbeat', {
timestamp: new Date().toISOString(),
socketId: socket.id,
transport: socket.io.engine.transport.name
}); });
} }
}, 15000);
// Handle transport upgrade
socket.io.engine.on('upgrade', function() {
console.log('Transport upgraded to:', socket.io.engine.transport.name);
}); });
socket.io.engine.on('upgradeError', function(err) { // Start polling for new messages
console.error('Transport upgrade error:', err); startPolling(conversationId);
// Fall back to polling
socket.io.opts.transports = ['polling'];
});
instance = { instance = {
socket: socket,
state: state, state: state,
cleanup: cleanup cleanup: cleanup
}; };
@@ -129,12 +47,97 @@ if (typeof window.ChatManager === 'undefined') {
return instance; return instance;
} }
function startPolling(conversationId) {
console.log('[ChatManager] Starting polling for conversation:', conversationId);
// Clear any existing polling
if (pollInterval) {
console.log('[ChatManager] Clearing existing polling interval');
clearInterval(pollInterval);
}
// Poll every 3 seconds
pollInterval = setInterval(() => {
console.log('[ChatManager] Polling interval triggered');
fetchNewMessages(conversationId);
}, 3000);
// Initial fetch
console.log('[ChatManager] Performing initial message fetch');
fetchNewMessages(conversationId);
}
function fetchNewMessages(conversationId) {
const url = `/conversations/${conversationId}/messages`;
const params = new URLSearchParams();
if (state.connectionState.lastMessageId) {
params.append('last_message_id', state.connectionState.lastMessageId);
}
console.log('[ChatManager] Fetching new messages:', {
url: url,
params: params.toString(),
lastMessageId: state.connectionState.lastMessageId
});
fetch(`${url}?${params.toString()}`)
.then(response => response.json())
.then(data => {
console.log('[ChatManager] Received messages response:', {
success: data.success,
messageCount: data.messages ? data.messages.length : 0,
messages: data.messages
});
if (data.success && data.messages) {
state.connectionState.pollAttempts = 0;
processNewMessages(data.messages);
}
})
.catch(error => {
console.error('[ChatManager] Error fetching messages:', error);
state.connectionState.pollAttempts++;
// If we've had too many failed attempts, try to reconnect
if (state.connectionState.pollAttempts > 5) {
console.log('[ChatManager] Too many failed attempts, restarting polling');
startPolling(conversationId);
}
});
}
function processNewMessages(messages) {
console.log('[ChatManager] Processing new messages:', {
messageCount: messages.length,
currentLastMessageId: state.connectionState.lastMessageId,
existingMessageIds: Array.from(state.addedMessageIds)
});
messages.forEach(message => {
console.log('[ChatManager] Processing message:', {
messageId: message.id,
alreadyAdded: state.addedMessageIds.has(message.id),
currentLastMessageId: state.connectionState.lastMessageId
});
if (!state.addedMessageIds.has(message.id)) {
state.addedMessageIds.add(message.id);
state.connectionState.lastMessageId = Math.max(state.connectionState.lastMessageId || 0, message.id);
console.log('[ChatManager] Triggering new_message event for message:', message.id);
// Trigger the new message event
$(document).trigger('new_message', [message]);
} else {
console.log('[ChatManager] Skipping already added message:', message.id);
}
});
}
function cleanup() { function cleanup() {
console.log('Cleaning up socket connection'); console.log('[ChatManager] Cleaning up polling');
if (socket) { if (pollInterval) {
socket.off('new_message'); clearInterval(pollInterval);
socket.disconnect(); pollInterval = null;
socket = null;
} }
instance = null; instance = null;
} }

View File

@@ -3,26 +3,28 @@ $(document).ready(function() {
const conversationId = window.conversationId; // Set this in the template const conversationId = window.conversationId; // Set this in the template
const currentUserId = window.currentUserId; // Set this in the template const currentUserId = window.currentUserId; // Set this in the template
const chat = ChatManager.getInstance(conversationId); const chat = ChatManager.getInstance(conversationId);
const socket = chat.socket;
const state = chat.state; const state = chat.state;
console.log('Initializing chat for conversation:', conversationId); console.log('[Conversation] Initializing chat for conversation:', conversationId);
// Join conversation room // Keep track of messages we've already displayed
socket.on('connect', function() { const displayedMessageIds = new Set();
if (!state.connectionState.hasJoined) {
console.log('Joining conversation room:', conversationId);
socket.emit('join_conversation', {
conversation_id: conversationId,
timestamp: new Date().toISOString(),
socketId: socket.id
});
state.connectionState.hasJoined = true;
}
});
// Function to append a new message to the chat // Function to append a new message to the chat
function appendMessage(message) { function appendMessage(message) {
console.log('[Conversation] Attempting to append message:', {
messageId: message.id,
content: message.content,
senderId: message.sender_id,
currentUserId: currentUserId
});
// Check if we've already displayed this message
if (displayedMessageIds.has(message.id)) {
return;
}
displayedMessageIds.add(message.id);
const isCurrentUser = message.sender_id === currentUserId; const isCurrentUser = message.sender_id === currentUserId;
const messageHtml = ` const messageHtml = `
<div class="message ${isCurrentUser ? 'sent' : 'received'}" data-message-id="${message.id}"> <div class="message ${isCurrentUser ? 'sent' : 'received'}" data-message-id="${message.id}">
@@ -65,9 +67,18 @@ $(document).ready(function() {
$('.text-center.text-muted').remove(); $('.text-center.text-muted').remove();
$('#chatMessages').append(messageHtml); $('#chatMessages').append(messageHtml);
console.log('[Conversation] Message appended to chat:', message.id);
scrollToBottom(); scrollToBottom();
} }
// Initialize displayedMessageIds with existing messages
$('.message').each(function() {
const messageId = $(this).data('message-id');
if (messageId) {
displayedMessageIds.add(messageId);
}
});
// Scroll to bottom of chat messages // Scroll to bottom of chat messages
function scrollToBottom() { function scrollToBottom() {
const chatMessages = document.getElementById('chatMessages'); const chatMessages = document.getElementById('chatMessages');
@@ -75,41 +86,14 @@ $(document).ready(function() {
} }
scrollToBottom(); scrollToBottom();
// Message handling with deduplication and reconnection handling // Listen for new messages
socket.on('new_message', function(message) { $(document).on('new_message', function(event, message) {
const timestamp = new Date().toISOString(); console.log('[Conversation] Received new_message event:', {
const messageKey = `${message.id}-${socket.id}`; messageId: message.id,
eventType: event.type,
console.log('Message received:', { timestamp: new Date().toISOString()
id: message.id,
timestamp: timestamp,
socketId: socket.id,
messageKey: messageKey,
queueSize: state.messageQueue.size
}); });
appendMessage(message);
if (state.messageQueue.has(messageKey)) {
console.log('Message already in queue:', messageKey);
return;
}
state.messageQueue.add(messageKey);
if (!state.addedMessageIds.has(message.id)) {
console.log('Processing new message:', message.id);
appendMessage(message);
state.connectionState.lastMessageId = message.id;
state.addedMessageIds.add(message.id);
} else {
console.log('Duplicate message detected:', {
messageId: message.id,
lastMessageId: state.connectionState.lastMessageId,
socketId: socket.id
});
}
// Clean up message from queue after processing
state.messageQueue.delete(messageKey);
}); });
// Handle file selection // Handle file selection
@@ -123,14 +107,14 @@ $(document).ready(function() {
} }
}); });
// Handle message form submission with better error handling // Handle message form submission
let isSubmitting = false; let isSubmitting = false;
$('#messageForm').off('submit').on('submit', function(e) { $('#messageForm').off('submit').on('submit', function(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (isSubmitting) { if (isSubmitting) {
console.log('Message submission already in progress'); console.log('[Conversation] Message submission already in progress');
return false; return false;
} }
@@ -143,15 +127,14 @@ $(document).ready(function() {
const files = Array.from(fileInput.files); const files = Array.from(fileInput.files);
if (!message && files.length === 0) { if (!message && files.length === 0) {
console.log('Empty message submission attempted'); console.log('[Conversation] Empty message submission attempted');
return false; return false;
} }
console.log('Submitting message:', { console.log('[Conversation] Submitting message:', {
hasText: !!message, hasText: !!message,
fileCount: files.length, fileCount: files.length,
socketId: socket.id, timestamp: new Date().toISOString()
connectionState: state.connectionState
}); });
isSubmitting = true; isSubmitting = true;
@@ -163,44 +146,46 @@ $(document).ready(function() {
const formData = new FormData(); const formData = new FormData();
formData.append('message', message); formData.append('message', message);
formData.append('csrf_token', $('input[name="csrf_token"]').val()); formData.append('csrf_token', $('input[name="csrf_token"]').val());
formData.append('socket_id', socket.id); formData.append('file_count', files.length);
files.forEach((file, index) => { files.forEach((file, index) => {
formData.append(`file_${index}`, file); formData.append(`file_${index}`, file);
}); });
formData.append('file_count', files.length);
$.ajax({ $.ajax({
url: window.sendMessageUrl, // Set this in the template url: window.sendMessageUrl,
method: 'POST', method: 'POST',
data: formData, data: formData,
processData: false, processData: false,
contentType: false, contentType: false,
success: function(response) { success: function(response) {
console.log('Message sent successfully:', { console.log('[Conversation] Message sent successfully:', {
response: response, response: response,
socketId: socket.id timestamp: new Date().toISOString()
}); });
if (response.success) { if (response.success) {
messageInput.val(''); messageInput.val('');
fileInput.value = ''; fileInput.value = '';
$('#selectedFiles').text(''); $('#selectedFiles').text('');
// If socket is disconnected, append message directly // Append the message directly since we sent it
if (!state.connectionState.isConnected && response.message) { if (response.message) {
console.log('Socket disconnected, appending message directly'); console.log('[Conversation] Appending sent message directly:', response.message.id);
// Update the ChatManager's lastMessageId
chat.state.connectionState.lastMessageId = response.message.id;
appendMessage(response.message); appendMessage(response.message);
} }
} else { } else {
console.error('Message send failed:', response); console.error('[Conversation] Message send failed:', response);
alert('Failed to send message. Please try again.'); alert('Failed to send message. Please try again.');
} }
}, },
error: function(xhr, status, error) { error: function(xhr, status, error) {
console.error('Failed to send message:', { console.error('[Conversation] Failed to send message:', {
status: status, status: status,
error: error, error: error,
response: xhr.responseText response: xhr.responseText,
timestamp: new Date().toISOString()
}); });
alert('Failed to send message. Please try again.'); alert('Failed to send message. Please try again.');
}, },
@@ -210,6 +195,7 @@ $(document).ready(function() {
submitIcon.removeClass('d-none'); submitIcon.removeClass('d-none');
spinner.addClass('d-none'); spinner.addClass('d-none');
isSubmitting = false; isSubmitting = false;
console.log('[Conversation] Message submission completed');
} }
}); });
@@ -218,6 +204,7 @@ $(document).ready(function() {
// Clean up on page unload // Clean up on page unload
$(window).on('beforeunload', function() { $(window).on('beforeunload', function() {
console.log('[Conversation] Cleaning up on page unload');
chat.cleanup(); chat.cleanup();
}); });
}); });

View File

@@ -0,0 +1,99 @@
{% macro logs_tab() %}
<div class="row">
<div class="col-12">
<!-- Filters -->
<div class="card mb-4">
<div class="card-body">
<form id="logFiltersForm" class="row g-3">
<div class="col-md-3">
<label for="logLevel" class="form-label">Log Level</label>
<select class="form-select" id="logLevel" name="level">
<option value="">All Levels</option>
<option value="INFO">Info</option>
<option value="WARNING">Warning</option>
<option value="ERROR">Error</option>
<option value="DEBUG">Debug</option>
</select>
</div>
<div class="col-md-3">
<label for="logCategory" class="form-label">Category</label>
<select class="form-select" id="logCategory" name="category">
<option value="">All Categories</option>
<option value="AUTH">Authentication</option>
<option value="FILE">File Operations</option>
<option value="USER">User Management</option>
<option value="SYSTEM">System</option>
</select>
</div>
<div class="col-md-3">
<label for="startDate" class="form-label">Start Date</label>
<input type="date" class="form-control" id="startDate" name="start_date">
</div>
<div class="col-md-3">
<label for="endDate" class="form-label">End Date</label>
<input type="date" class="form-control" id="endDate" name="end_date">
</div>
<div class="col-md-6">
<label for="searchLogs" class="form-label">Search</label>
<input type="text" class="form-control" id="searchLogs" name="search" placeholder="Search in logs...">
</div>
<div class="col-md-6 d-flex align-items-end">
<button type="submit" class="btn btn-primary me-2">
<i class="fas fa-search me-1"></i> Apply Filters
</button>
<button type="reset" class="btn btn-secondary">
<i class="fas fa-undo me-1"></i> Reset
</button>
</div>
</form>
</div>
</div>
<!-- Logs Table -->
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Timestamp</th>
<th>Level</th>
<th>Category</th>
<th>Action</th>
<th>Description</th>
<th>User</th>
<th>IP Address</th>
</tr>
</thead>
<tbody id="logsTableBody">
<!-- Logs will be loaded here via JavaScript -->
</tbody>
</table>
</div>
<!-- Pagination -->
<nav aria-label="Logs pagination" class="mt-4">
<ul class="pagination justify-content-center" id="logsPagination">
<!-- Pagination will be loaded here via JavaScript -->
</ul>
</nav>
</div>
</div>
</div>
</div>
<!-- Log Details Modal -->
<div class="modal fade" id="logDetailsModal" tabindex="-1" aria-labelledby="logDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="logDetailsModalLabel">Log Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="logDetailsContent">
<!-- Log details will be loaded here -->
</div>
</div>
</div>
</div>
{% endmacro %}