diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..c74d306 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,5 @@ +""" +mc-webui - Flask application package +""" + +__version__ = "0.1.0" diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..c09ad7f --- /dev/null +++ b/app/config.py @@ -0,0 +1,46 @@ +""" +Configuration module - loads settings from environment variables +""" + +import os +from pathlib import Path + + +class Config: + """Application configuration from environment variables""" + + # MeshCore device configuration + MC_SERIAL_PORT = os.getenv('MC_SERIAL_PORT', '/dev/ttyUSB0') + MC_DEVICE_NAME = os.getenv('MC_DEVICE_NAME', 'MeshCore') + MC_CONFIG_DIR = os.getenv('MC_CONFIG_DIR', '/root/.config/meshcore') + + # Application settings + MC_REFRESH_INTERVAL = int(os.getenv('MC_REFRESH_INTERVAL', '60')) + MC_INACTIVE_HOURS = int(os.getenv('MC_INACTIVE_HOURS', '48')) + + # Flask server configuration + FLASK_HOST = os.getenv('FLASK_HOST', '0.0.0.0') + FLASK_PORT = int(os.getenv('FLASK_PORT', '5000')) + FLASK_DEBUG = os.getenv('FLASK_DEBUG', 'false').lower() == 'true' + + # Derived paths + @property + def msgs_file_path(self) -> Path: + """Get the full path to the .msgs file""" + return Path(self.MC_CONFIG_DIR) / f"{self.MC_DEVICE_NAME}.msgs" + + @property + def meshcli_command(self) -> list: + """Get the base meshcli command with serial port""" + return ['meshcli', '-s', self.MC_SERIAL_PORT] + + def __repr__(self): + return ( + f"Config(device={self.MC_DEVICE_NAME}, " + f"port={self.MC_SERIAL_PORT}, " + f"config_dir={self.MC_CONFIG_DIR})" + ) + + +# Global config instance +config = Config() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..7ec0800 --- /dev/null +++ b/app/main.py @@ -0,0 +1,45 @@ +""" +mc-webui - Flask application entry point +""" + +import logging +from flask import Flask +from app.config import config +from app.routes.views import views_bp +from app.routes.api import api_bp + +# Configure logging +logging.basicConfig( + level=logging.DEBUG if config.FLASK_DEBUG else logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +logger = logging.getLogger(__name__) + + +def create_app(): + """Create and configure Flask application""" + app = Flask(__name__) + + # Load configuration + app.config['DEBUG'] = config.FLASK_DEBUG + app.config['SECRET_KEY'] = 'mc-webui-secret-key-change-in-production' + + # Register blueprints + app.register_blueprint(views_bp) + app.register_blueprint(api_bp) + + logger.info(f"mc-webui started - device: {config.MC_DEVICE_NAME}") + logger.info(f"Messages file: {config.msgs_file_path}") + logger.info(f"Serial port: {config.MC_SERIAL_PORT}") + + return app + + +if __name__ == '__main__': + app = create_app() + app.run( + host=config.FLASK_HOST, + port=config.FLASK_PORT, + debug=config.FLASK_DEBUG + ) diff --git a/app/meshcore/__init__.py b/app/meshcore/__init__.py new file mode 100644 index 0000000..31e0fb0 --- /dev/null +++ b/app/meshcore/__init__.py @@ -0,0 +1,3 @@ +""" +MeshCore integration package - CLI wrapper and message parser +""" diff --git a/app/meshcore/cli.py b/app/meshcore/cli.py new file mode 100644 index 0000000..45da771 --- /dev/null +++ b/app/meshcore/cli.py @@ -0,0 +1,146 @@ +""" +MeshCore CLI wrapper - executes meshcli commands via subprocess +""" + +import subprocess +import logging +from typing import Tuple, Optional +from app.config import config + +logger = logging.getLogger(__name__) + +# Command timeout in seconds +DEFAULT_TIMEOUT = 30 +RECV_TIMEOUT = 60 # recv can take longer + + +class MeshCLIError(Exception): + """Custom exception for meshcli command failures""" + pass + + +def _run_command(args: list, timeout: int = DEFAULT_TIMEOUT) -> Tuple[bool, str, str]: + """ + Execute a meshcli command and return result. + + Args: + args: Command arguments (will be prepended with meshcli -s ) + timeout: Command timeout in seconds + + Returns: + Tuple of (success, stdout, stderr) + """ + cmd = config.meshcli_command + args + logger.info(f"Executing: {' '.join(cmd)}") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + check=False + ) + + success = result.returncode == 0 + stdout = result.stdout.strip() + stderr = result.stderr.strip() + + if not success: + logger.warning(f"Command failed: {stderr or stdout}") + + return success, stdout, stderr + + except subprocess.TimeoutExpired: + logger.error(f"Command timeout after {timeout}s: {' '.join(cmd)}") + return False, "", f"Command timeout after {timeout}s" + + except FileNotFoundError: + logger.error("meshcli command not found") + return False, "", "meshcli not found - is meshcore-cli installed?" + + except Exception as e: + logger.error(f"Unexpected error: {e}") + return False, "", str(e) + + +def recv_messages() -> Tuple[bool, str]: + """ + Fetch new messages from the device. + + Returns: + Tuple of (success, message) + """ + success, stdout, stderr = _run_command(['recv'], timeout=RECV_TIMEOUT) + return success, stdout or stderr + + +def send_message(text: str, reply_to: Optional[str] = None) -> Tuple[bool, str]: + """ + Send a message to the Public channel. + + Args: + text: Message content + reply_to: Optional username to reply to (will format as @[username]) + + Returns: + Tuple of (success, message) + """ + if reply_to: + message = f"@[{reply_to}] {text}" + else: + message = text + + success, stdout, stderr = _run_command(['public', message]) + return success, stdout or stderr + + +def get_contacts() -> Tuple[bool, str]: + """ + Get list of contacts from the device. + + Returns: + Tuple of (success, output) + """ + success, stdout, stderr = _run_command(['contacts']) + return success, stdout or stderr + + +def clean_inactive_contacts(hours: int = 48) -> Tuple[bool, str]: + """ + Remove contacts inactive for specified hours. + + Args: + hours: Inactivity threshold in hours + + Returns: + Tuple of (success, message) + """ + # Command format: apply_to u<48h,t=1 remove_contact + # u<48h = updated less than 48h ago (inactive) + # t=1 = type client (not router/repeater) + filter_cmd = f"u<{hours}h,t=1" + success, stdout, stderr = _run_command(['apply_to', filter_cmd, 'remove_contact']) + return success, stdout or stderr + + +def get_device_info() -> Tuple[bool, str]: + """ + Get device information. + + Returns: + Tuple of (success, info) + """ + success, stdout, stderr = _run_command(['infos']) + return success, stdout or stderr + + +def check_connection() -> bool: + """ + Quick check if device is accessible. + + Returns: + True if device responds, False otherwise + """ + success, _, _ = _run_command(['infos'], timeout=5) + return success diff --git a/app/meshcore/parser.py b/app/meshcore/parser.py new file mode 100644 index 0000000..1c6ac76 --- /dev/null +++ b/app/meshcore/parser.py @@ -0,0 +1,149 @@ +""" +Message parser - reads and parses .msgs file (JSON Lines format) +""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional +from datetime import datetime +from app.config import config + +logger = logging.getLogger(__name__) + + +def parse_message(line: Dict) -> Optional[Dict]: + """ + Parse a single message line from .msgs file. + + Args: + line: Raw JSON object from .msgs file + + Returns: + Parsed message dict or None if not a valid chat message + """ + msg_type = line.get('type') + channel_idx = line.get('channel_idx', 0) + + # Only process Public channel (channel 0) messages + if channel_idx != 0: + return None + + # Only process CHAN (received) and SENT_CHAN (sent) messages + if msg_type not in ['CHAN', 'SENT_CHAN']: + return None + + timestamp = line.get('timestamp', 0) + text = line.get('text', '').strip() + + if not text: + return None + + # Determine if message is sent or received + is_own = msg_type == 'SENT_CHAN' + + # Extract sender name + if is_own: + # For sent messages, use device name from config or 'name' field + sender = line.get('name', config.MC_DEVICE_NAME) + content = text + else: + # For received messages, extract sender from "SenderName: message" format + if ':' in text: + sender, content = text.split(':', 1) + sender = sender.strip() + content = content.strip() + else: + # Fallback if format is unexpected + sender = "Unknown" + content = text + + return { + 'sender': sender, + 'content': content, + 'timestamp': timestamp, + 'datetime': datetime.fromtimestamp(timestamp).isoformat() if timestamp > 0 else None, + 'is_own': is_own, + 'snr': line.get('SNR'), + 'path_len': line.get('path_len') + } + + +def read_messages(limit: Optional[int] = None, offset: int = 0) -> List[Dict]: + """ + Read and parse messages from .msgs file. + + Args: + limit: Maximum number of messages to return (None = all) + offset: Number of messages to skip from the end + + Returns: + List of parsed message dictionaries, sorted by timestamp (oldest first) + """ + msgs_file = config.msgs_file_path + + if not msgs_file.exists(): + logger.warning(f"Messages file not found: {msgs_file}") + return [] + + messages = [] + + try: + with open(msgs_file, 'r', encoding='utf-8') as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + + try: + data = json.loads(line) + parsed = parse_message(data) + if parsed: + messages.append(parsed) + except json.JSONDecodeError as e: + logger.warning(f"Invalid JSON at line {line_num}: {e}") + continue + except Exception as e: + logger.error(f"Error parsing line {line_num}: {e}") + continue + + except FileNotFoundError: + logger.error(f"Messages file not found: {msgs_file}") + return [] + except Exception as e: + logger.error(f"Error reading messages file: {e}") + return [] + + # Sort by timestamp (oldest first) + messages.sort(key=lambda m: m['timestamp']) + + # Apply offset and limit + if offset > 0: + messages = messages[:-offset] if offset < len(messages) else [] + + if limit is not None and limit > 0: + messages = messages[-limit:] + + logger.info(f"Loaded {len(messages)} messages from {msgs_file}") + return messages + + +def get_latest_message() -> Optional[Dict]: + """ + Get the most recent message. + + Returns: + Latest message dict or None if no messages + """ + messages = read_messages(limit=1) + return messages[0] if messages else None + + +def count_messages() -> int: + """ + Count total number of messages in the file. + + Returns: + Message count + """ + return len(read_messages()) diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..3607e15 --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1,3 @@ +""" +Flask routes - API endpoints and HTML views +""" diff --git a/app/routes/api.py b/app/routes/api.py new file mode 100644 index 0000000..04253a3 --- /dev/null +++ b/app/routes/api.py @@ -0,0 +1,237 @@ +""" +REST API endpoints for mc-webui +""" + +import logging +from flask import Blueprint, jsonify, request +from app.meshcore import cli, parser +from app.config import config + +logger = logging.getLogger(__name__) + +api_bp = Blueprint('api', __name__, url_prefix='/api') + + +@api_bp.route('/messages', methods=['GET']) +def get_messages(): + """ + Get list of messages from Public channel. + + Query parameters: + limit (int): Maximum number of messages to return + offset (int): Number of messages to skip from the end + + Returns: + JSON with messages list + """ + try: + limit = request.args.get('limit', type=int) + offset = request.args.get('offset', default=0, type=int) + + messages = parser.read_messages(limit=limit, offset=offset) + + return jsonify({ + 'success': True, + 'count': len(messages), + 'messages': messages + }), 200 + + except Exception as e: + logger.error(f"Error fetching messages: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@api_bp.route('/messages', methods=['POST']) +def send_message(): + """ + Send a message to the Public channel. + + JSON body: + text (str): Message content (required) + reply_to (str): Username to reply to (optional) + + Returns: + JSON with success status + """ + try: + data = request.get_json() + + if not data or 'text' not in data: + return jsonify({ + 'success': False, + 'error': 'Missing required field: text' + }), 400 + + text = data['text'].strip() + if not text: + return jsonify({ + 'success': False, + 'error': 'Message text cannot be empty' + }), 400 + + reply_to = data.get('reply_to') + + # Send message via meshcli + success, message = cli.send_message(text, reply_to=reply_to) + + if success: + return jsonify({ + 'success': True, + 'message': 'Message sent successfully' + }), 200 + else: + return jsonify({ + 'success': False, + 'error': message + }), 500 + + except Exception as e: + logger.error(f"Error sending message: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@api_bp.route('/status', methods=['GET']) +def get_status(): + """ + Get device connection status and basic info. + + Returns: + JSON with status information + """ + try: + # Check if device is accessible + connected = cli.check_connection() + + # Get message count + message_count = parser.count_messages() + + # Get latest message timestamp + latest = parser.get_latest_message() + latest_timestamp = latest['timestamp'] if latest else None + + return jsonify({ + 'success': True, + 'connected': connected, + 'device_name': config.MC_DEVICE_NAME, + 'serial_port': config.MC_SERIAL_PORT, + 'message_count': message_count, + 'latest_message_timestamp': latest_timestamp, + 'refresh_interval': config.MC_REFRESH_INTERVAL + }), 200 + + except Exception as e: + logger.error(f"Error getting status: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@api_bp.route('/contacts/cleanup', methods=['POST']) +def cleanup_contacts(): + """ + Clean up inactive contacts. + + JSON body: + hours (int): Inactivity threshold in hours (optional, default from config) + + Returns: + JSON with cleanup result + """ + try: + data = request.get_json() or {} + hours = data.get('hours', config.MC_INACTIVE_HOURS) + + if not isinstance(hours, int) or hours < 1: + return jsonify({ + 'success': False, + 'error': 'Invalid hours value (must be positive integer)' + }), 400 + + # Execute cleanup command + success, message = cli.clean_inactive_contacts(hours) + + if success: + return jsonify({ + 'success': True, + 'message': f'Cleanup completed: {message}', + 'hours': hours + }), 200 + else: + return jsonify({ + 'success': False, + 'error': message + }), 500 + + except Exception as e: + logger.error(f"Error cleaning contacts: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@api_bp.route('/device/info', methods=['GET']) +def get_device_info(): + """ + Get detailed device information. + + Returns: + JSON with device info + """ + try: + success, info = cli.get_device_info() + + if success: + return jsonify({ + 'success': True, + 'info': info + }), 200 + else: + return jsonify({ + 'success': False, + 'error': info + }), 500 + + except Exception as e: + logger.error(f"Error getting device info: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@api_bp.route('/sync', methods=['POST']) +def sync_messages(): + """ + Trigger message sync from device. + + Returns: + JSON with sync result + """ + try: + success, message = cli.recv_messages() + + if success: + return jsonify({ + 'success': True, + 'message': 'Messages synced successfully' + }), 200 + else: + return jsonify({ + 'success': False, + 'error': message + }), 500 + + except Exception as e: + logger.error(f"Error syncing messages: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 diff --git a/app/routes/views.py b/app/routes/views.py new file mode 100644 index 0000000..d728bcd --- /dev/null +++ b/app/routes/views.py @@ -0,0 +1,31 @@ +""" +HTML views for mc-webui +""" + +import logging +from flask import Blueprint, render_template +from app.config import config + +logger = logging.getLogger(__name__) + +views_bp = Blueprint('views', __name__) + + +@views_bp.route('/') +def index(): + """ + Main chat view - displays message list and send form. + """ + return render_template( + 'index.html', + device_name=config.MC_DEVICE_NAME, + refresh_interval=config.MC_REFRESH_INTERVAL + ) + + +@views_bp.route('/health') +def health(): + """ + Health check endpoint for monitoring. + """ + return 'OK', 200 diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..bfa18ff --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,186 @@ +/* mc-webui Custom Styles */ + +:root { + --msg-own-bg: #e7f1ff; + --msg-other-bg: #f8f9fa; + --msg-border: #dee2e6; +} + +/* Page Layout */ +html, body { + height: 100vh; + margin: 0; + overflow: hidden; +} + +body { + display: flex; + flex-direction: column; +} + +main { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* Messages Container */ +.messages-container { + background-color: #ffffff; +} + +#messagesList { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Message Bubbles */ +.message { + max-width: 70%; + padding: 0.75rem 1rem; + border-radius: 1rem; + border: 1px solid var(--msg-border); + word-wrap: break-word; + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Own Messages (right-aligned) */ +.message.own { + align-self: flex-end; + background-color: var(--msg-own-bg); + border-color: #b8daff; +} + +/* Other Messages (left-aligned) */ +.message.other { + align-self: flex-start; + background-color: var(--msg-other-bg); +} + +/* Message Header */ +.message-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.25rem; + font-size: 0.875rem; +} + +.message-sender { + font-weight: 600; + color: #0d6efd; +} + +.message.own .message-sender { + color: #084298; +} + +.message-time { + font-size: 0.75rem; + color: #6c757d; +} + +/* Message Content */ +.message-content { + margin: 0; + white-space: pre-wrap; + line-height: 1.4; +} + +/* Message Metadata */ +.message-meta { + font-size: 0.7rem; + color: #adb5bd; + margin-top: 0.25rem; +} + +/* Reply Button */ +.btn-reply { + font-size: 0.7rem; + padding: 0.1rem 0.4rem; + margin-top: 0.25rem; +} + +/* Send Form */ +#messageInput { + resize: none; + border-radius: 0.5rem 0 0 0.5rem; +} + +#sendBtn { + border-radius: 0 0.5rem 0.5rem 0; +} + +/* Status Indicators */ +.status-connected { + color: #198754 !important; +} + +.status-disconnected { + color: #dc3545 !important; +} + +.status-connecting { + color: #ffc107 !important; +} + +/* Scrollbar Styling */ +.messages-container::-webkit-scrollbar { + width: 8px; +} + +.messages-container::-webkit-scrollbar-track { + background: #f1f1f1; +} + +.messages-container::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} + +.messages-container::-webkit-scrollbar-thumb:hover { + background: #555; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .message { + max-width: 85%; + } + + .message-header { + font-size: 0.8rem; + } + + #messageInput { + font-size: 0.9rem; + } +} + +/* Loading State */ +.loading-spinner { + display: flex; + justify-content: center; + align-items: center; + height: 100%; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: #6c757d; +} + +.empty-state i { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.5; +} diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100644 index 0000000..3d4cc37 --- /dev/null +++ b/app/static/js/app.js @@ -0,0 +1,372 @@ +/** + * mc-webui Frontend Application + */ + +// Global state +let lastMessageCount = 0; +let autoRefreshInterval = null; +let isUserScrolling = false; + +// Initialize on page load +document.addEventListener('DOMContentLoaded', function() { + console.log('mc-webui initialized'); + + // Load initial messages + loadMessages(); + + // Setup auto-refresh + setupAutoRefresh(); + + // Setup event listeners + setupEventListeners(); + + // Load device status + loadStatus(); +}); + +/** + * Setup event listeners + */ +function setupEventListeners() { + // Send message form + const form = document.getElementById('sendMessageForm'); + const input = document.getElementById('messageInput'); + + form.addEventListener('submit', function(e) { + e.preventDefault(); + sendMessage(); + }); + + // Handle Enter key (send) vs Shift+Enter (new line) + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }); + + // Manual refresh button + document.getElementById('refreshBtn').addEventListener('click', function() { + loadMessages(); + }); + + // Cleanup contacts button + document.getElementById('cleanupBtn').addEventListener('click', function() { + cleanupContacts(); + }); + + // Track user scrolling + const container = document.getElementById('messagesContainer'); + container.addEventListener('scroll', function() { + const isAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 100; + isUserScrolling = !isAtBottom; + }); + + // Load device info when settings modal opens + const settingsModal = document.getElementById('settingsModal'); + settingsModal.addEventListener('show.bs.modal', function() { + loadDeviceInfo(); + }); +} + +/** + * Load messages from API + */ +async function loadMessages() { + try { + const response = await fetch('/api/messages?limit=100'); + const data = await response.json(); + + if (data.success) { + displayMessages(data.messages); + updateStatus('connected'); + updateLastRefresh(); + } else { + showNotification('Error loading messages: ' + data.error, 'danger'); + } + } catch (error) { + console.error('Error loading messages:', error); + updateStatus('disconnected'); + showNotification('Failed to load messages', 'danger'); + } +} + +/** + * Display messages in the UI + */ +function displayMessages(messages) { + const container = document.getElementById('messagesList'); + const wasAtBottom = !isUserScrolling; + + // Clear loading spinner + container.innerHTML = ''; + + if (messages.length === 0) { + container.innerHTML = ` +
+ +

No messages yet

+ Send a message to get started! +
+ `; + return; + } + + // Render each message + messages.forEach(msg => { + const messageEl = createMessageElement(msg); + container.appendChild(messageEl); + }); + + // Auto-scroll to bottom if user wasn't scrolling + if (wasAtBottom) { + scrollToBottom(); + } + + lastMessageCount = messages.length; +} + +/** + * Create message DOM element + */ +function createMessageElement(msg) { + const div = document.createElement('div'); + div.className = `message ${msg.is_own ? 'own' : 'other'}`; + + const time = formatTime(msg.timestamp); + + let metaInfo = ''; + if (msg.snr !== undefined && msg.snr !== null) { + metaInfo += `SNR: ${msg.snr.toFixed(1)} dB`; + } + if (msg.path_len !== undefined && msg.path_len !== null) { + metaInfo += ` | Hops: ${msg.path_len}`; + } + + div.innerHTML = ` +
+ ${escapeHtml(msg.sender)} + ${time} +
+

${escapeHtml(msg.content)}

+ ${metaInfo ? `
${metaInfo}
` : ''} + ${!msg.is_own ? `` : ''} + `; + + return div; +} + +/** + * Send a message + */ +async function sendMessage() { + const input = document.getElementById('messageInput'); + const text = input.value.trim(); + + if (!text) return; + + const sendBtn = document.getElementById('sendBtn'); + sendBtn.disabled = true; + + try { + const response = await fetch('/api/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ text: text }) + }); + + const data = await response.json(); + + if (data.success) { + input.value = ''; + showNotification('Message sent', 'success'); + + // Reload messages after short delay + setTimeout(() => loadMessages(), 1000); + } else { + showNotification('Failed to send: ' + data.error, 'danger'); + } + } catch (error) { + console.error('Error sending message:', error); + showNotification('Failed to send message', 'danger'); + } finally { + sendBtn.disabled = false; + input.focus(); + } +} + +/** + * Reply to a user + */ +function replyTo(username) { + const input = document.getElementById('messageInput'); + input.value = `@[${username}] `; + input.focus(); +} + +/** + * Load connection status + */ +async function loadStatus() { + try { + const response = await fetch('/api/status'); + const data = await response.json(); + + if (data.success) { + updateStatus(data.connected ? 'connected' : 'disconnected'); + } + } catch (error) { + console.error('Error loading status:', error); + updateStatus('disconnected'); + } +} + +/** + * Load device information + */ +async function loadDeviceInfo() { + const infoEl = document.getElementById('deviceInfo'); + infoEl.innerHTML = '
Loading...'; + + try { + const response = await fetch('/api/device/info'); + const data = await response.json(); + + if (data.success) { + infoEl.innerHTML = `
${escapeHtml(data.info)}
`; + } else { + infoEl.innerHTML = `Error: ${escapeHtml(data.error)}`; + } + } catch (error) { + infoEl.innerHTML = 'Failed to load device info'; + } +} + +/** + * Cleanup inactive contacts + */ +async function cleanupContacts() { + const hours = parseInt(document.getElementById('inactiveHours').value); + + if (!confirm(`Remove all contacts inactive for more than ${hours} hours?`)) { + return; + } + + const btn = document.getElementById('cleanupBtn'); + btn.disabled = true; + + try { + const response = await fetch('/api/contacts/cleanup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ hours: hours }) + }); + + const data = await response.json(); + + if (data.success) { + showNotification(data.message, 'success'); + } else { + showNotification('Cleanup failed: ' + data.error, 'danger'); + } + } catch (error) { + console.error('Error cleaning contacts:', error); + showNotification('Cleanup failed', 'danger'); + } finally { + btn.disabled = false; + } +} + +/** + * Setup auto-refresh + */ +function setupAutoRefresh() { + const interval = window.MC_CONFIG?.refreshInterval || 60000; + + autoRefreshInterval = setInterval(() => { + loadMessages(); + }, interval); + + console.log(`Auto-refresh enabled: every ${interval / 1000}s`); +} + +/** + * Update connection status indicator + */ +function updateStatus(status) { + const statusEl = document.getElementById('statusText'); + + const icons = { + connected: ' Connected', + disconnected: ' Disconnected', + connecting: ' Connecting...' + }; + + statusEl.innerHTML = icons[status] || icons.connecting; +} + +/** + * Update last refresh timestamp + */ +function updateLastRefresh() { + const now = new Date(); + const timeStr = now.toLocaleTimeString(); + document.getElementById('lastRefresh').textContent = `Last refresh: ${timeStr}`; +} + +/** + * Show notification toast + */ +function showNotification(message, type = 'info') { + const toastEl = document.getElementById('notificationToast'); + const toastBody = toastEl.querySelector('.toast-body'); + + toastBody.textContent = message; + toastEl.className = `toast bg-${type} text-white`; + + const toast = new bootstrap.Toast(toastEl); + toast.show(); +} + +/** + * Scroll to bottom of messages + */ +function scrollToBottom() { + const container = document.getElementById('messagesContainer'); + container.scrollTop = container.scrollHeight; +} + +/** + * Format timestamp + */ +function formatTime(timestamp) { + const date = new Date(timestamp * 1000); + const now = new Date(); + const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + // Today - show time only + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } else if (diffDays === 1) { + // Yesterday + return 'Yesterday ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } else { + // Older - show date and time + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } +} + +/** + * Escape HTML to prevent XSS + */ +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..9b1de4a --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,94 @@ + + + + + + {% block title %}mc-webui{% endblock %} + + + + + + + + + + {% block extra_head %}{% endblock %} + + + + + + +
+ {% block content %}{% endblock %} +
+ + + + + +
+ +
+ + + + + + + + {% block extra_scripts %}{% endblock %} + + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..9e83c67 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} + +{% block title %}Chat - mc-webui{% endblock %} + +{% block content %} +
+ +
+
+
+
+ +
+
+ Loading... +
+

Loading messages...

+
+
+
+
+
+ + +
+
+
+
+ + +
+ Press Shift+Enter for new line, Enter to send +
+
+
+ + +
+
+
+ + Connecting... + + Last refresh: Never +
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %}