mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-03-28 17:42:45 +01:00
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:
5
app/__init__.py
Normal file
5
app/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
mc-webui - Flask application package
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
46
app/config.py
Normal file
46
app/config.py
Normal 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
45
app/main.py
Normal 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
3
app/meshcore/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
MeshCore integration package - CLI wrapper and message parser
|
||||
"""
|
||||
146
app/meshcore/cli.py
Normal file
146
app/meshcore/cli.py
Normal 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
149
app/meshcore/parser.py
Normal 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
3
app/routes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Flask routes - API endpoints and HTML views
|
||||
"""
|
||||
237
app/routes/api.py
Normal file
237
app/routes/api.py
Normal 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
31
app/routes/views.py
Normal 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
186
app/static/css/style.css
Normal 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
372
app/static/js/app.js
Normal 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
94
app/templates/base.html
Normal 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
67
app/templates/index.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user