Phase 1: Backend basics - Complete Flask application with REST API

Implemented core backend functionality:
- Flask application structure with blueprints
- Configuration module loading from environment variables
- MeshCore CLI wrapper with subprocess execution and timeout handling
- Message parser for .msgs JSON Lines file format
- REST API endpoints (messages, status, sync, contacts cleanup)
- HTML views with Bootstrap 5 responsive design
- Frontend JavaScript with auto-refresh and live updates
- Custom CSS styling for chat interface

API Endpoints:
- GET  /api/messages - List messages with pagination
- POST /api/messages - Send message with optional reply-to
- GET  /api/status - Device connection status
- POST /api/sync - Trigger message sync
- POST /api/contacts/cleanup - Remove inactive contacts
- GET  /api/device/info - Device information

Features:
- Auto-refresh every 60s (configurable)
- Reply to messages with @[UserName] format
- Toast notifications for feedback
- Settings modal for contact management
- Responsive design (mobile-friendly)
- Message bubbles with sender, timestamp, SNR, hop count

Ready for testing on production server (192.168.131.80:5000)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2025-12-21 14:02:46 +01:00
parent 3248dd2d63
commit cf456422e2
13 changed files with 1384 additions and 0 deletions

5
app/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""
mc-webui - Flask application package
"""
__version__ = "0.1.0"

46
app/config.py Normal file
View File

@@ -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()

45
app/main.py Normal file
View File

@@ -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
)

3
app/meshcore/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
MeshCore integration package - CLI wrapper and message parser
"""

146
app/meshcore/cli.py Normal file
View File

@@ -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 <port>)
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

149
app/meshcore/parser.py Normal file
View File

@@ -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())

3
app/routes/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
Flask routes - API endpoints and HTML views
"""

237
app/routes/api.py Normal file
View File

@@ -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

31
app/routes/views.py Normal file
View File

@@ -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

186
app/static/css/style.css Normal file
View File

@@ -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;
}

372
app/static/js/app.js Normal file
View File

@@ -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 = `
<div class="empty-state">
<i class="bi bi-chat-dots"></i>
<p>No messages yet</p>
<small>Send a message to get started!</small>
</div>
`;
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 = `
<div class="message-header">
<span class="message-sender">${escapeHtml(msg.sender)}</span>
<span class="message-time">${time}</span>
</div>
<p class="message-content">${escapeHtml(msg.content)}</p>
${metaInfo ? `<div class="message-meta">${metaInfo}</div>` : ''}
${!msg.is_own ? `<button class="btn btn-outline-secondary btn-sm btn-reply" onclick="replyTo('${escapeHtml(msg.sender)}')">
<i class="bi bi-reply"></i> Reply
</button>` : ''}
`;
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 = '<div class="spinner-border spinner-border-sm"></div> Loading...';
try {
const response = await fetch('/api/device/info');
const data = await response.json();
if (data.success) {
infoEl.innerHTML = `<pre class="mb-0">${escapeHtml(data.info)}</pre>`;
} else {
infoEl.innerHTML = `<span class="text-danger">Error: ${escapeHtml(data.error)}</span>`;
}
} catch (error) {
infoEl.innerHTML = '<span class="text-danger">Failed to load device info</span>';
}
}
/**
* 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: '<i class="bi bi-circle-fill status-connected"></i> Connected',
disconnected: '<i class="bi bi-circle-fill status-disconnected"></i> Disconnected',
connecting: '<i class="bi bi-circle-fill status-connecting"></i> 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;
}

94
app/templates/base.html Normal file
View File

@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}mc-webui{% endblock %}</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block extra_head %}{% endblock %}
</head>
<body>
<!-- Navbar -->
<nav class="navbar navbar-dark bg-primary">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">
<i class="bi bi-broadcast"></i> mc-webui
{% if device_name %}
<small class="text-white-50">- {{ device_name }}</small>
{% endif %}
</span>
<div class="d-flex align-items-center">
<button class="btn btn-outline-light btn-sm me-2" id="refreshBtn" title="Refresh messages">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
<button class="btn btn-outline-light btn-sm" data-bs-toggle="modal" data-bs-target="#settingsModal" title="Settings">
<i class="bi bi-gear"></i>
</button>
</div>
</div>
</nav>
<!-- Main Content -->
<main>
{% block content %}{% endblock %}
</main>
<!-- Settings Modal -->
<div class="modal fade" id="settingsModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-gear"></i> Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<h6>Contact Management</h6>
<hr>
<div class="mb-3">
<label for="inactiveHours" class="form-label">Remove contacts inactive for:</label>
<div class="input-group">
<input type="number" class="form-control" id="inactiveHours" value="48" min="1">
<span class="input-group-text">hours</span>
</div>
</div>
<button class="btn btn-danger" id="cleanupBtn">
<i class="bi bi-trash"></i> Clean Inactive Contacts
</button>
<hr>
<h6>Device Information</h6>
<div id="deviceInfo" class="small text-muted">
Loading...
</div>
</div>
</div>
</div>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="notificationToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">mc-webui</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body"></div>
</div>
</div>
<!-- Bootstrap 5 JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

67
app/templates/index.html Normal file
View File

@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}Chat - mc-webui{% endblock %}
{% block content %}
<div class="container-fluid h-100 d-flex flex-column">
<!-- Messages Container -->
<div class="row flex-grow-1 overflow-hidden">
<div class="col-12 h-100">
<div id="messagesContainer" class="messages-container h-100 overflow-auto p-3">
<div id="messagesList">
<!-- Messages will be loaded here via JavaScript -->
<div class="text-center text-muted py-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3">Loading messages...</p>
</div>
</div>
</div>
</div>
</div>
<!-- Send Message Form -->
<div class="row border-top bg-light">
<div class="col-12">
<form id="sendMessageForm" class="p-3">
<div class="input-group">
<textarea
id="messageInput"
class="form-control"
placeholder="Type a message..."
rows="2"
required
></textarea>
<button type="submit" class="btn btn-primary px-4" id="sendBtn">
<i class="bi bi-send"></i> Send
</button>
</div>
<small class="text-muted">Press Shift+Enter for new line, Enter to send</small>
</form>
</div>
</div>
<!-- Status Bar -->
<div class="row border-top">
<div class="col-12">
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
<span id="statusText">
<i class="bi bi-circle-fill text-secondary"></i> Connecting...
</span>
<span id="lastRefresh">Last refresh: Never</span>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Pass configuration from Flask to JavaScript
window.MC_CONFIG = {
refreshInterval: {{ refresh_interval }} * 1000, // Convert to milliseconds
deviceName: "{{ device_name }}"
};
</script>
{% endblock %}