updated view and database
This commit is contained in:
+21
-2
@@ -42,6 +42,20 @@ def _latest_config_ts(session, mac_address):
|
|||||||
return global_ts, device_ts, latest
|
return global_ts, device_ts, latest
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_wmt_checkin(device):
|
||||||
|
"""Stamp a device as having checked in via the WMT client API.
|
||||||
|
|
||||||
|
Keeps the unified device record in sync: both the general monitoring
|
||||||
|
'last_seen' and the WMT-specific 'wmt_last_seen' are advanced on every
|
||||||
|
client interaction so the unified devices page reflects live state.
|
||||||
|
"""
|
||||||
|
if device is None:
|
||||||
|
return
|
||||||
|
now = datetime.utcnow()
|
||||||
|
device.last_seen = now
|
||||||
|
device.wmt_last_seen = now
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Endpoints
|
# Endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -67,6 +81,10 @@ def get_config_timestamp():
|
|||||||
with get_db().get_session() as session:
|
with get_db().get_session() as session:
|
||||||
global_ts, device_info_reviewed_ts, latest = _latest_config_ts(session, mac)
|
global_ts, device_info_reviewed_ts, latest = _latest_config_ts(session, mac)
|
||||||
|
|
||||||
|
# Stamp WMT check-in so the unified device record stays live
|
||||||
|
device = session.query(Device).filter_by(mac_address=mac).first()
|
||||||
|
_mark_wmt_checkin(device)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'global_updated_at': global_ts.isoformat() if global_ts != datetime(1970, 1, 1) else None,
|
'global_updated_at': global_ts.isoformat() if global_ts != datetime(1970, 1, 1) else None,
|
||||||
'device_info_reviewed_at': device_info_reviewed_ts.isoformat() if device_info_reviewed_ts != datetime(1970, 1, 1) else None,
|
'device_info_reviewed_at': device_info_reviewed_ts.isoformat() if device_info_reviewed_ts != datetime(1970, 1, 1) else None,
|
||||||
@@ -91,9 +109,9 @@ def get_device_config(mac_address):
|
|||||||
global_cfg = _get_or_create_global_config(session)
|
global_cfg = _get_or_create_global_config(session)
|
||||||
device = session.query(Device).filter_by(mac_address=mac).first()
|
device = session.query(Device).filter_by(mac_address=mac).first()
|
||||||
|
|
||||||
# Update last_seen if device is known
|
# Update last_seen / wmt_last_seen if device is known
|
||||||
if device:
|
if device:
|
||||||
device.last_seen = datetime.utcnow()
|
_mark_wmt_checkin(device)
|
||||||
|
|
||||||
_, device_ts, latest_ts = _latest_config_ts(session, mac)
|
_, device_ts, latest_ts = _latest_config_ts(session, mac)
|
||||||
|
|
||||||
@@ -214,6 +232,7 @@ def submit_update_request():
|
|||||||
|
|
||||||
# Device is known from here on ─────────────────────────────
|
# Device is known from here on ─────────────────────────────
|
||||||
device.last_seen = datetime.utcnow()
|
device.last_seen = datetime.utcnow()
|
||||||
|
_mark_wmt_checkin(device)
|
||||||
if card_presence in ('enable', 'disable'):
|
if card_presence in ('enable', 'disable'):
|
||||||
device.card_presence = card_presence
|
device.card_presence = card_presence
|
||||||
|
|
||||||
|
|||||||
+27
-1
@@ -4,7 +4,7 @@ Database models for enhanced server monitoring system
|
|||||||
from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, ForeignKey, LargeBinary, Float, Table
|
from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, ForeignKey, LargeBinary, Float, Table
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
@@ -53,6 +53,9 @@ class Device(Base):
|
|||||||
info_reviewed_at = Column(DateTime, default=lambda: datetime(1970, 1, 1))
|
info_reviewed_at = Column(DateTime, default=lambda: datetime(1970, 1, 1))
|
||||||
card_presence = Column(String(10), default='enable')
|
card_presence = Column(String(10), default='enable')
|
||||||
custom_chrome_url = Column(String(500), nullable=True) # per-device production URL override (overrides WMTGlobalConfig.chrome_url)
|
custom_chrome_url = Column(String(500), nullable=True) # per-device production URL override (overrides WMTGlobalConfig.chrome_url)
|
||||||
|
# Stamped on every WMT client check-in (config pull / update request / timestamp poll).
|
||||||
|
# Authoritative signal that a device is "WMT enabled" on the unified devices page.
|
||||||
|
wmt_last_seen = Column(DateTime, nullable=True, index=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
logs = relationship("LogEntry", back_populates="device")
|
logs = relationship("LogEntry", back_populates="device")
|
||||||
@@ -67,6 +70,29 @@ class Device(Base):
|
|||||||
"""Alias for nume_masa – used by WMT module."""
|
"""Alias for nume_masa – used by WMT module."""
|
||||||
return self.nume_masa
|
return self.nume_masa
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wmt_enabled(self):
|
||||||
|
"""True when this device has ever checked in via the WMT client API.
|
||||||
|
|
||||||
|
This is the authoritative "WMT enabled" signal for the unified devices
|
||||||
|
page (replaces the old 'has a MAC address' heuristic).
|
||||||
|
"""
|
||||||
|
return self.wmt_last_seen is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_online(self):
|
||||||
|
"""Heuristic online state: active status and seen within the last 10 minutes."""
|
||||||
|
if self.status != 'active' or not self.last_seen:
|
||||||
|
return False
|
||||||
|
return (datetime.utcnow() - self.last_seen) <= timedelta(minutes=10)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def minutes_since_last_seen(self):
|
||||||
|
"""Whole minutes since the device was last seen (None if never seen)."""
|
||||||
|
if not self.last_seen:
|
||||||
|
return None
|
||||||
|
return int((datetime.utcnow() - self.last_seen).total_seconds() // 60)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Device(hostname='{self.hostname}', ip='{self.device_ip}')>"
|
return f"<Device(hostname='{self.hostname}', ip='{self.device_ip}')>"
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class AnsibleService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.db = get_db()
|
self._db = None
|
||||||
self.ansible_dir = Path("ansible")
|
self.ansible_dir = Path("ansible")
|
||||||
self.inventory_file = self.ansible_dir / "inventory" / "dynamic_inventory.yaml"
|
self.inventory_file = self.ansible_dir / "inventory" / "dynamic_inventory.yaml"
|
||||||
self.playbook_dir = self.ansible_dir / "playbooks"
|
self.playbook_dir = self.ansible_dir / "playbooks"
|
||||||
@@ -42,6 +42,13 @@ class AnsibleService:
|
|||||||
(self.ansible_dir / "roles").mkdir(exist_ok=True)
|
(self.ansible_dir / "roles").mkdir(exist_ok=True)
|
||||||
self.ssh_keys_dir.mkdir(mode=0o700, exist_ok=True)
|
self.ssh_keys_dir.mkdir(mode=0o700, exist_ok=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def db(self):
|
||||||
|
"""Lazily resolve the database handle (see LogCompressionService.db)."""
|
||||||
|
if self._db is None:
|
||||||
|
self._db = get_db()
|
||||||
|
return self._db
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Settings helpers #
|
# Settings helpers #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -13,9 +13,16 @@ class DeviceService:
|
|||||||
"""Service for managing devices and device-related operations"""
|
"""Service for managing devices and device-related operations"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.db = get_db()
|
self._db = None
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def db(self):
|
||||||
|
"""Lazily resolve the database handle (see LogCompressionService.db)."""
|
||||||
|
if self._db is None:
|
||||||
|
self._db = get_db()
|
||||||
|
return self._db
|
||||||
|
|
||||||
# Basic CRUD Operations
|
# Basic CRUD Operations
|
||||||
|
|
||||||
def create_device(self, hostname: str, device_ip: str, nume_masa: str, **kwargs) -> Device:
|
def create_device(self, hostname: str, device_ip: str, nume_masa: str, **kwargs) -> Device:
|
||||||
@@ -213,7 +220,10 @@ class DeviceService:
|
|||||||
'recent_logs_24h': recent_logs,
|
'recent_logs_24h': recent_logs,
|
||||||
'total_files': total_files,
|
'total_files': total_files,
|
||||||
'last_log': last_log,
|
'last_log': last_log,
|
||||||
'uptime_days': (datetime.utcnow() - device.last_seen).days if device.last_seen else 0
|
# Days since the device was last seen (0 = seen today / currently active).
|
||||||
|
# Previously mislabeled as "uptime_days" which implied the opposite.
|
||||||
|
'days_since_last_seen': (datetime.utcnow() - device.last_seen).days if device.last_seen else None,
|
||||||
|
'is_online': device.is_online,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ from pathlib import Path
|
|||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
from app.models import Device, FileUpload
|
from app.models import Device, FileUpload
|
||||||
from config.database_config import get_db
|
from config.database_config import get_db
|
||||||
|
from sqlalchemy import func
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
class FileUploadService:
|
class FileUploadService:
|
||||||
"""Service for handling file uploads and processing"""
|
"""Service for handling file uploads and processing"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.db = get_db()
|
self._db = None
|
||||||
self.upload_folder = Path("data/uploads")
|
self.upload_folder = Path("data/uploads")
|
||||||
self.upload_folder.mkdir(exist_ok=True)
|
self.upload_folder.mkdir(exist_ok=True)
|
||||||
|
|
||||||
@@ -28,6 +29,13 @@ class FileUploadService:
|
|||||||
# Max file size (50MB)
|
# Max file size (50MB)
|
||||||
self.max_file_size = 50 * 1024 * 1024
|
self.max_file_size = 50 * 1024 * 1024
|
||||||
|
|
||||||
|
@property
|
||||||
|
def db(self):
|
||||||
|
"""Lazily resolve the database handle (see LogCompressionService.db)."""
|
||||||
|
if self._db is None:
|
||||||
|
self._db = get_db()
|
||||||
|
return self._db
|
||||||
|
|
||||||
def process_uploaded_file(self, file, device_info):
|
def process_uploaded_file(self, file, device_info):
|
||||||
"""Process uploaded file from device"""
|
"""Process uploaded file from device"""
|
||||||
try:
|
try:
|
||||||
@@ -105,7 +113,7 @@ class FileUploadService:
|
|||||||
|
|
||||||
# Process file content if it's a log file
|
# Process file content if it's a log file
|
||||||
if self._is_log_file(filename, mime_type):
|
if self._is_log_file(filename, mime_type):
|
||||||
self._process_log_file(file_upload, file_content)
|
self._process_log_file(session, file_upload, file_content)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
@@ -176,8 +184,12 @@ class FileUploadService:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _process_log_file(self, file_upload, content):
|
def _process_log_file(self, session, file_upload, content):
|
||||||
"""Process log file content to extract log entries"""
|
"""Process log file content to extract log entries.
|
||||||
|
|
||||||
|
Reuses the caller's database session (no nested per-line sessions) by
|
||||||
|
calling LogCompressionService.store_log_in_session for every line.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Mark as log file
|
# Mark as log file
|
||||||
file_upload.is_log_file = True
|
file_upload.is_log_file = True
|
||||||
@@ -189,11 +201,7 @@ class FileUploadService:
|
|||||||
from app.services.log_service import LogCompressionService
|
from app.services.log_service import LogCompressionService
|
||||||
log_service = LogCompressionService()
|
log_service = LogCompressionService()
|
||||||
|
|
||||||
device_info = {
|
device = file_upload.device
|
||||||
'hostname': file_upload.device.hostname,
|
|
||||||
'device_ip': file_upload.device.device_ip,
|
|
||||||
'nume_masa': file_upload.device.nume_masa
|
|
||||||
}
|
|
||||||
|
|
||||||
for line_num, line in enumerate(lines, 1):
|
for line_num, line in enumerate(lines, 1):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
@@ -204,14 +212,13 @@ class FileUploadService:
|
|||||||
# This is a simple implementation - enhance as needed
|
# This is a simple implementation - enhance as needed
|
||||||
message = f"[File: {file_upload.original_filename}:{line_num}] {line}"
|
message = f"[File: {file_upload.original_filename}:{line_num}] {line}"
|
||||||
|
|
||||||
# Process through log compression service
|
# Store within the SAME session – avoids opening a session per line
|
||||||
result = log_service.process_log_message(
|
log_service.store_log_in_session(
|
||||||
device_info=device_info,
|
session=session,
|
||||||
|
device=device,
|
||||||
message=message,
|
message=message,
|
||||||
severity='info'
|
severity='info'
|
||||||
)
|
)
|
||||||
|
|
||||||
if result['success']:
|
|
||||||
entries_extracted += 1
|
entries_extracted += 1
|
||||||
|
|
||||||
# Update file record
|
# Update file record
|
||||||
@@ -235,7 +242,7 @@ class FileUploadService:
|
|||||||
|
|
||||||
# Calculate total size
|
# Calculate total size
|
||||||
total_size = session.query(
|
total_size = session.query(
|
||||||
session.func.sum(FileUpload.file_size)
|
func.sum(FileUpload.file_size)
|
||||||
).scalar() or 0
|
).scalar() or 0
|
||||||
|
|
||||||
# Count by processing status
|
# Count by processing status
|
||||||
|
|||||||
+125
-103
@@ -15,47 +15,66 @@ class LogCompressionService:
|
|||||||
"""Service for compressing log messages using templates and aliases"""
|
"""Service for compressing log messages using templates and aliases"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.db = get_db()
|
self._db = None
|
||||||
self.template_patterns = self._load_common_patterns()
|
self.template_patterns = self._load_common_patterns()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def db(self):
|
||||||
|
"""Lazily resolve the database handle so creating this service at import
|
||||||
|
time does not initialize the DB before the Flask app context exists."""
|
||||||
|
if self._db is None:
|
||||||
|
self._db = get_db()
|
||||||
|
return self._db
|
||||||
|
|
||||||
def _load_common_patterns(self) -> List[Dict]:
|
def _load_common_patterns(self) -> List[Dict]:
|
||||||
"""Load common log message patterns for template matching"""
|
"""Load common log message patterns for template matching.
|
||||||
|
|
||||||
|
Each entry's 'variables' list maps regex capture groups (in order) to
|
||||||
|
template variable names, so matching/extraction is fully data-driven and
|
||||||
|
no longer relies on a hardcoded if/elif chain.
|
||||||
|
"""
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
'pattern': r'Card detected: ([A-F0-9]+)',
|
'pattern': r'Card detected: ([A-F0-9]+)',
|
||||||
'template': 'Card detected: {card_id}',
|
'template': 'Card detected: {card_id}',
|
||||||
'category': 'card_detection',
|
'category': 'card_detection',
|
||||||
'alias_prefix': 'CD'
|
'alias_prefix': 'CD',
|
||||||
|
'variables': ['card_id'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'pattern': r'Connection failed: (.+)',
|
'pattern': r'Connection failed: (.+)',
|
||||||
'template': 'Connection failed: {error}',
|
'template': 'Connection failed: {error}',
|
||||||
'category': 'connection_error',
|
'category': 'connection_error',
|
||||||
'alias_prefix': 'CE'
|
'alias_prefix': 'CE',
|
||||||
|
'variables': ['error'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'pattern': r'System startup completed in ([0-9.]+)s',
|
'pattern': r'System startup completed in ([0-9.]+)s',
|
||||||
'template': 'System startup completed in {time}s',
|
'template': 'System startup completed in {time}s',
|
||||||
'category': 'system_startup',
|
'category': 'system_startup',
|
||||||
'alias_prefix': 'SS'
|
'alias_prefix': 'SS',
|
||||||
|
'variables': ['time'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'pattern': r'Auto-update: (.+)',
|
'pattern': r'Auto-update: (.+)',
|
||||||
'template': 'Auto-update: {message}',
|
'template': 'Auto-update: {message}',
|
||||||
'category': 'auto_update',
|
'category': 'auto_update',
|
||||||
'alias_prefix': 'AU'
|
'alias_prefix': 'AU',
|
||||||
|
'variables': ['message'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'pattern': r'Command \'([^\']+)\' (SUCCESS|FAILED)',
|
'pattern': r'Command \'([^\']+)\' (SUCCESS|FAILED)',
|
||||||
'template': 'Command \'{command}\' {status}',
|
'template': 'Command \'{command}\' {status}',
|
||||||
'category': 'command_execution',
|
'category': 'command_execution',
|
||||||
'alias_prefix': 'EX'
|
'alias_prefix': 'EX',
|
||||||
|
'variables': ['command', 'status'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'pattern': r'Temperature: ([0-9.]+)°C',
|
'pattern': r'Temperature: ([0-9.]+)°C',
|
||||||
'template': 'Temperature: {temp}°C',
|
'template': 'Temperature: {temp}°C',
|
||||||
'category': 'temperature',
|
'category': 'temperature',
|
||||||
'alias_prefix': 'TM'
|
'alias_prefix': 'TM',
|
||||||
|
'variables': ['temp'],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -76,72 +95,9 @@ class LogCompressionService:
|
|||||||
# Get or create device
|
# Get or create device
|
||||||
device = self._get_or_create_device(session, device_info)
|
device = self._get_or_create_device(session, device_info)
|
||||||
|
|
||||||
# Try to match message to existing template
|
log_entry, compression_info = self.store_log_in_session(
|
||||||
template, variables = self._match_message_template(session, message)
|
session, device, message, severity
|
||||||
|
|
||||||
if template:
|
|
||||||
# Use existing template
|
|
||||||
log_entry = LogEntry(
|
|
||||||
device_id=device.id,
|
|
||||||
template_id=template.id,
|
|
||||||
template_variables=json.dumps(variables) if variables else None,
|
|
||||||
severity=severity,
|
|
||||||
timestamp=datetime.utcnow()
|
|
||||||
)
|
)
|
||||||
# Update template usage count
|
|
||||||
template.usage_count += 1
|
|
||||||
|
|
||||||
# Calculate size savings
|
|
||||||
original_size = len(message.encode('utf-8'))
|
|
||||||
compressed_size = len(template.alias.encode('utf-8')) + \
|
|
||||||
len(json.dumps(variables or {}).encode('utf-8'))
|
|
||||||
|
|
||||||
compression_info = {
|
|
||||||
'used_template': True,
|
|
||||||
'template_alias': template.alias,
|
|
||||||
'original_size': original_size,
|
|
||||||
'compressed_size': compressed_size,
|
|
||||||
'savings_percent': ((original_size - compressed_size) / original_size) * 100
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# Create new template if message matches a pattern
|
|
||||||
template = self._create_new_template(session, message)
|
|
||||||
|
|
||||||
if template:
|
|
||||||
# New template created
|
|
||||||
variables = self._extract_variables(message, template.template_text)
|
|
||||||
log_entry = LogEntry(
|
|
||||||
device_id=device.id,
|
|
||||||
template_id=template.id,
|
|
||||||
template_variables=json.dumps(variables) if variables else None,
|
|
||||||
severity=severity,
|
|
||||||
timestamp=datetime.utcnow()
|
|
||||||
)
|
|
||||||
template.usage_count = 1
|
|
||||||
|
|
||||||
compression_info = {
|
|
||||||
'used_template': True,
|
|
||||||
'template_alias': template.alias,
|
|
||||||
'new_template': True,
|
|
||||||
'original_size': len(message.encode('utf-8')),
|
|
||||||
'compressed_size': len(template.alias.encode('utf-8'))
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# Store as full message
|
|
||||||
log_entry = LogEntry(
|
|
||||||
device_id=device.id,
|
|
||||||
full_message=message,
|
|
||||||
severity=severity,
|
|
||||||
timestamp=datetime.utcnow()
|
|
||||||
)
|
|
||||||
|
|
||||||
compression_info = {
|
|
||||||
'used_template': False,
|
|
||||||
'stored_full': True,
|
|
||||||
'original_size': len(message.encode('utf-8'))
|
|
||||||
}
|
|
||||||
|
|
||||||
session.add(log_entry)
|
|
||||||
session.flush() # Get the log entry ID
|
session.flush() # Get the log entry ID
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -160,6 +116,72 @@ class LogCompressionService:
|
|||||||
'message': 'Log processing failed'
|
'message': 'Log processing failed'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def store_log_in_session(self, session: Session, device: Device,
|
||||||
|
message: str, severity: str = 'info') -> Tuple[LogEntry, Dict]:
|
||||||
|
"""Compress and store a single log message using an existing session/device.
|
||||||
|
|
||||||
|
Shared by process_log_message() and bulk file ingestion so callers don't
|
||||||
|
open a new database session per log line. The caller is responsible for
|
||||||
|
committing (the session context manager does this).
|
||||||
|
Returns (log_entry, compression_info).
|
||||||
|
"""
|
||||||
|
# Try to match message to existing template
|
||||||
|
template, variables = self._match_message_template(session, message)
|
||||||
|
|
||||||
|
if template:
|
||||||
|
log_entry = LogEntry(
|
||||||
|
device_id=device.id,
|
||||||
|
template_id=template.id,
|
||||||
|
template_variables=json.dumps(variables) if variables else None,
|
||||||
|
severity=severity,
|
||||||
|
timestamp=datetime.utcnow()
|
||||||
|
)
|
||||||
|
template.usage_count += 1
|
||||||
|
|
||||||
|
original_size = len(message.encode('utf-8'))
|
||||||
|
compressed_size = len(template.alias.encode('utf-8')) + \
|
||||||
|
len(json.dumps(variables or {}).encode('utf-8'))
|
||||||
|
compression_info = {
|
||||||
|
'used_template': True,
|
||||||
|
'template_alias': template.alias,
|
||||||
|
'original_size': original_size,
|
||||||
|
'compressed_size': compressed_size,
|
||||||
|
'savings_percent': ((original_size - compressed_size) / original_size) * 100
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
template, variables = self._create_new_template(session, message)
|
||||||
|
if template:
|
||||||
|
log_entry = LogEntry(
|
||||||
|
device_id=device.id,
|
||||||
|
template_id=template.id,
|
||||||
|
template_variables=json.dumps(variables) if variables else None,
|
||||||
|
severity=severity,
|
||||||
|
timestamp=datetime.utcnow()
|
||||||
|
)
|
||||||
|
template.usage_count = 1
|
||||||
|
compression_info = {
|
||||||
|
'used_template': True,
|
||||||
|
'template_alias': template.alias,
|
||||||
|
'new_template': True,
|
||||||
|
'original_size': len(message.encode('utf-8')),
|
||||||
|
'compressed_size': len(template.alias.encode('utf-8'))
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
log_entry = LogEntry(
|
||||||
|
device_id=device.id,
|
||||||
|
full_message=message,
|
||||||
|
severity=severity,
|
||||||
|
timestamp=datetime.utcnow()
|
||||||
|
)
|
||||||
|
compression_info = {
|
||||||
|
'used_template': False,
|
||||||
|
'stored_full': True,
|
||||||
|
'original_size': len(message.encode('utf-8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
session.add(log_entry)
|
||||||
|
return log_entry, compression_info
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _infer_device_type(hostname: str) -> str:
|
def _infer_device_type(hostname: str) -> str:
|
||||||
"""Guess device type from hostname pattern."""
|
"""Guess device type from hostname pattern."""
|
||||||
@@ -252,34 +274,22 @@ class LogCompressionService:
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if template:
|
if template:
|
||||||
# Extract variables
|
variables = self._variables_from_match(pattern_info, match)
|
||||||
variables = {}
|
|
||||||
for i, group in enumerate(match.groups(), 1):
|
|
||||||
# Map to variable names based on template
|
|
||||||
if '{card_id}' in template_text and pattern_info['category'] == 'card_detection':
|
|
||||||
variables['card_id'] = group
|
|
||||||
elif '{error}' in template_text and pattern_info['category'] == 'connection_error':
|
|
||||||
variables['error'] = group
|
|
||||||
elif '{time}' in template_text and pattern_info['category'] == 'system_startup':
|
|
||||||
variables['time'] = group
|
|
||||||
elif '{message}' in template_text:
|
|
||||||
variables['message'] = group
|
|
||||||
elif '{command}' in template_text and i == 1:
|
|
||||||
variables['command'] = group
|
|
||||||
elif '{status}' in template_text and i == 2:
|
|
||||||
variables['status'] = group
|
|
||||||
elif '{temp}' in template_text:
|
|
||||||
variables['temp'] = group
|
|
||||||
|
|
||||||
return template, variables
|
return template, variables
|
||||||
|
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
def _create_new_template(self, session: Session, message: str) -> Optional[MessageTemplate]:
|
def _create_new_template(self, session: Session, message: str) -> Tuple[Optional[MessageTemplate], Optional[Dict]]:
|
||||||
"""Create new template if message matches a known pattern"""
|
"""Create new template if message matches a known pattern.
|
||||||
|
|
||||||
|
Returns (template, variables) so the caller can persist the extracted
|
||||||
|
variables on the first log entry that creates the template.
|
||||||
|
"""
|
||||||
for pattern_info in self.template_patterns:
|
for pattern_info in self.template_patterns:
|
||||||
match = re.match(pattern_info['pattern'], message)
|
match = re.match(pattern_info['pattern'], message)
|
||||||
if match:
|
if match:
|
||||||
|
variables = self._variables_from_match(pattern_info, match)
|
||||||
|
|
||||||
# Check if template already exists
|
# Check if template already exists
|
||||||
existing = session.query(MessageTemplate).filter_by(
|
existing = session.query(MessageTemplate).filter_by(
|
||||||
template_text=pattern_info['template'],
|
template_text=pattern_info['template'],
|
||||||
@@ -287,7 +297,7 @@ class LogCompressionService:
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
return existing
|
return existing, variables
|
||||||
|
|
||||||
# Create new template
|
# Create new template
|
||||||
alias = self._generate_alias(session, pattern_info['alias_prefix'])
|
alias = self._generate_alias(session, pattern_info['alias_prefix'])
|
||||||
@@ -303,9 +313,9 @@ class LogCompressionService:
|
|||||||
|
|
||||||
session.add(template)
|
session.add(template)
|
||||||
session.flush()
|
session.flush()
|
||||||
return template
|
return template, variables
|
||||||
|
|
||||||
return None
|
return None, None
|
||||||
|
|
||||||
def _generate_alias(self, session: Session, prefix: str) -> str:
|
def _generate_alias(self, session: Session, prefix: str) -> str:
|
||||||
"""Generate unique alias for template"""
|
"""Generate unique alias for template"""
|
||||||
@@ -325,12 +335,24 @@ class LogCompressionService:
|
|||||||
return f"{prefix}{max_num + 1:03d}"
|
return f"{prefix}{max_num + 1:03d}"
|
||||||
|
|
||||||
def _extract_variables(self, message: str, template: str) -> Dict:
|
def _extract_variables(self, message: str, template: str) -> Dict:
|
||||||
"""Extract variables from message using template"""
|
"""Extract template variables from a raw message.
|
||||||
# Simple variable extraction - could be enhanced
|
|
||||||
variables = {}
|
Finds the matching pattern for the message and maps its capture groups
|
||||||
# This is a simplified implementation
|
to the configured variable names. Returns {} when no pattern matches.
|
||||||
# In production, you'd want more sophisticated template matching
|
"""
|
||||||
return variables
|
for pattern_info in self.template_patterns:
|
||||||
|
if pattern_info['template'] != template:
|
||||||
|
continue
|
||||||
|
match = re.match(pattern_info['pattern'], message)
|
||||||
|
if match:
|
||||||
|
return self._variables_from_match(pattern_info, match)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _variables_from_match(pattern_info: Dict, match) -> Dict:
|
||||||
|
"""Map a regex match's capture groups to named template variables."""
|
||||||
|
names = pattern_info.get('variables') or []
|
||||||
|
return {name: value for name, value in zip(names, match.groups())}
|
||||||
|
|
||||||
def get_compression_stats(self) -> Dict:
|
def get_compression_stats(self) -> Dict:
|
||||||
"""Get compression statistics"""
|
"""Get compression statistics"""
|
||||||
|
|||||||
+22
-18
@@ -2,7 +2,7 @@
|
|||||||
Main web routes for dashboard and device management
|
Main web routes for dashboard and device management
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||||
from app.models import Device, LogEntry, MessageTemplate, AnsibleExecution, WMTUpdateRequest, InventoryGroup, device_inventory_association
|
from app.models import Device, LogEntry, MessageTemplate, AnsibleExecution, PlaybookExecution, WMTUpdateRequest, InventoryGroup, device_inventory_association
|
||||||
from config.database_config import get_db
|
from config.database_config import get_db
|
||||||
from app.services.log_service import LogCompressionService
|
from app.services.log_service import LogCompressionService
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -242,10 +242,12 @@ def stats():
|
|||||||
|
|
||||||
# Get execution statistics
|
# Get execution statistics
|
||||||
exec_stats = {
|
exec_stats = {
|
||||||
'total': session.query(AnsibleExecution).count(),
|
'total': session.query(PlaybookExecution).count(),
|
||||||
'successful': session.query(AnsibleExecution).filter_by(status='completed').count(),
|
'successful': session.query(PlaybookExecution).filter_by(status='completed').count(),
|
||||||
'failed': session.query(AnsibleExecution).filter_by(status='failed').count(),
|
'failed': session.query(PlaybookExecution).filter_by(status='failed').count(),
|
||||||
'running': session.query(AnsibleExecution).filter_by(status='running').count()
|
'running': session.query(PlaybookExecution).filter(
|
||||||
|
PlaybookExecution.status.in_(['queued', 'running'])
|
||||||
|
).count()
|
||||||
}
|
}
|
||||||
|
|
||||||
return render_template('stats.html',
|
return render_template('stats.html',
|
||||||
@@ -541,6 +543,21 @@ def admin():
|
|||||||
@main_bp.route('/admin/clear/logs', methods=['POST'])
|
@main_bp.route('/admin/clear/logs', methods=['POST'])
|
||||||
def admin_clear_logs():
|
def admin_clear_logs():
|
||||||
"""Delete all log entries from the database."""
|
"""Delete all log entries from the database."""
|
||||||
|
return _clear_all_log_entries()
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/admin/clear/device-logs', methods=['POST'])
|
||||||
|
def admin_clear_device_logs():
|
||||||
|
"""Delete all log entries from the database (devices stay intact).
|
||||||
|
|
||||||
|
Functionally identical to admin_clear_logs – both endpoints are kept because
|
||||||
|
the admin UI exposes two separate buttons, but they share one implementation.
|
||||||
|
"""
|
||||||
|
return _clear_all_log_entries()
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_all_log_entries():
|
||||||
|
"""Shared implementation: delete every LogEntry row."""
|
||||||
try:
|
try:
|
||||||
with get_db().get_session() as session:
|
with get_db().get_session() as session:
|
||||||
count = session.query(LogEntry).delete()
|
count = session.query(LogEntry).delete()
|
||||||
@@ -551,19 +568,6 @@ def admin_clear_logs():
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route('/admin/clear/device-logs', methods=['POST'])
|
|
||||||
def admin_clear_device_logs():
|
|
||||||
"""Delete all log entries from the database (devices stay intact)."""
|
|
||||||
try:
|
|
||||||
with get_db().get_session() as session:
|
|
||||||
count = session.query(LogEntry).delete()
|
|
||||||
session.commit()
|
|
||||||
return jsonify({'success': True, 'deleted': count})
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f'Admin clear device logs error: {e}')
|
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route('/admin/delete/device/<int:device_id>', methods=['POST'])
|
@main_bp.route('/admin/delete/device/<int:device_id>', methods=['POST'])
|
||||||
def admin_delete_device(device_id):
|
def admin_delete_device(device_id):
|
||||||
"""Delete a single registered device and its log entries."""
|
"""Delete a single registered device and its log entries."""
|
||||||
|
|||||||
+2
-21
@@ -216,27 +216,8 @@ def reject_request(req_id):
|
|||||||
|
|
||||||
@wmt_web_bp.route('/devices')
|
@wmt_web_bp.route('/devices')
|
||||||
def devices():
|
def devices():
|
||||||
"""List all WMT-registered devices."""
|
"""Retired: WMT devices are now shown on the unified Devices page."""
|
||||||
try:
|
return redirect(url_for('main.devices'))
|
||||||
with get_db().get_session() as session:
|
|
||||||
device_list = (
|
|
||||||
session.query(Device)
|
|
||||||
.filter(Device.mac_address.isnot(None))
|
|
||||||
.order_by(Device.nume_masa)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
return render_template(
|
|
||||||
'wmt/devices.html',
|
|
||||||
devices=device_list,
|
|
||||||
breadcrumbs=[
|
|
||||||
{'url': url_for('wmt_web.index'), 'title': 'WMT Management'},
|
|
||||||
{'url': url_for('wmt_web.devices'), 'title': 'Devices'},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'WMT devices list error: {e}')
|
|
||||||
flash(f'Error: {e}', 'error')
|
|
||||||
return redirect(url_for('wmt_web.index'))
|
|
||||||
|
|
||||||
|
|
||||||
@wmt_web_bp.route('/devices/new', methods=['GET', 'POST'])
|
@wmt_web_bp.route('/devices/new', methods=['GET', 'POST'])
|
||||||
|
|||||||
@@ -42,12 +42,42 @@ class DatabaseConfig:
|
|||||||
"""Create all database tables"""
|
"""Create all database tables"""
|
||||||
try:
|
try:
|
||||||
Base.metadata.create_all(self.engine)
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.ensure_schema()
|
||||||
logging.info("Database tables created successfully")
|
logging.info("Database tables created successfully")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error creating database tables: {e}")
|
logging.error(f"Error creating database tables: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def ensure_schema(self):
|
||||||
|
"""Idempotently add columns that were introduced after the table was first
|
||||||
|
created. SQLAlchemy's create_all() never ALTERs existing tables, so new
|
||||||
|
columns on an existing SQLite database must be added manually.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
# Columns added over time: (table, column, SQL type definition)
|
||||||
|
required_columns = [
|
||||||
|
('devices', 'wmt_last_seen', 'DATETIME'),
|
||||||
|
('devices', 'config_synced_at', 'DATETIME'),
|
||||||
|
('devices', 'custom_chrome_url', 'VARCHAR(500)'),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
with self.engine.connect() as conn:
|
||||||
|
for table, column, col_type in required_columns:
|
||||||
|
exists = conn.execute(
|
||||||
|
text("SELECT name FROM sqlite_master WHERE type='table' AND name=:t"),
|
||||||
|
{'t': table},
|
||||||
|
).fetchone()
|
||||||
|
if not exists:
|
||||||
|
continue
|
||||||
|
cols = [row[1] for row in conn.execute(text(f"PRAGMA table_info({table})")).fetchall()]
|
||||||
|
if column not in cols:
|
||||||
|
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info(f"Schema: added column {table}.{column}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error ensuring schema columns: {e}")
|
||||||
|
|
||||||
def drop_tables(self):
|
def drop_tables(self):
|
||||||
"""Drop all database tables (use with caution!)"""
|
"""Drop all database tables (use with caution!)"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ def main():
|
|||||||
else:
|
else:
|
||||||
print("❌ Database initialization failed")
|
print("❌ Database initialization failed")
|
||||||
return 1
|
return 1
|
||||||
|
else:
|
||||||
|
# Existing database – make sure any newly added columns are present
|
||||||
|
db.ensure_schema()
|
||||||
|
|
||||||
# Create initial dummy data if database is empty (for testing)
|
# Create initial dummy data if database is empty (for testing)
|
||||||
create_sample_data_if_needed()
|
create_sample_data_if_needed()
|
||||||
|
|||||||
+9
-11
@@ -867,6 +867,14 @@
|
|||||||
|
|
||||||
<ul class="nav-menu">
|
<ul class="nav-menu">
|
||||||
|
|
||||||
|
<!-- ── Unified Devices (WMT + Live View health) ── -->
|
||||||
|
<li class="nav-item" style="padding: 0 0px;">
|
||||||
|
<a href="{{ url_for('main.devices') }}"
|
||||||
|
class="nav-link {% if request.endpoint in ['main.devices','main.device_edit','main.device_detail','wmt_web.device_edit','wmt_web.device_new'] %}active{% endif %}">
|
||||||
|
<i class="fas fa-desktop"></i>Devices
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<!-- ── WMT group ── -->
|
<!-- ── WMT group ── -->
|
||||||
<li class="nav-group" id="group-wmt">
|
<li class="nav-group" id="group-wmt">
|
||||||
<div class="nav-group-header {% if request.endpoint and request.endpoint.startswith('wmt_web') %}open{% endif %}"
|
<div class="nav-group-header {% if request.endpoint and request.endpoint.startswith('wmt_web') %}open{% endif %}"
|
||||||
@@ -882,11 +890,6 @@
|
|||||||
<i class="fas fa-tachometer-alt"></i>Dashboard
|
<i class="fas fa-tachometer-alt"></i>Dashboard
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
|
||||||
<a href="{{ url_for('wmt_web.devices') }}" class="nav-link {% if request.endpoint in ['wmt_web.devices','wmt_web.device_new','wmt_web.device_edit'] %}active{% endif %}">
|
|
||||||
<i class="fas fa-desktop"></i>Devices
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a href="{{ url_for('wmt_web.update_requests') }}" class="nav-link {% if request.endpoint == 'wmt_web.update_requests' %}active{% endif %}">
|
<a href="{{ url_for('wmt_web.update_requests') }}" class="nav-link {% if request.endpoint == 'wmt_web.update_requests' %}active{% endif %}">
|
||||||
<i class="fas fa-inbox"></i>Update Requests
|
<i class="fas fa-inbox"></i>Update Requests
|
||||||
@@ -907,7 +910,7 @@
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- ── Live View group ── -->
|
<!-- ── Live View group ── -->
|
||||||
{% set lv_active = request.endpoint in ['main.devices','main.device_edit','main.device_detail','main.logs','main.templates','main.stats'] %}
|
{% set lv_active = request.endpoint in ['main.logs','main.templates','main.stats'] %}
|
||||||
<li class="nav-group" id="group-liveview">
|
<li class="nav-group" id="group-liveview">
|
||||||
<div class="nav-group-header {% if lv_active %}open{% endif %}"
|
<div class="nav-group-header {% if lv_active %}open{% endif %}"
|
||||||
onclick="toggleGroup('group-liveview')">
|
onclick="toggleGroup('group-liveview')">
|
||||||
@@ -916,11 +919,6 @@
|
|||||||
<i class="fas fa-chevron-right chevron"></i>
|
<i class="fas fa-chevron-right chevron"></i>
|
||||||
</div>
|
</div>
|
||||||
<ul class="nav-group-children {% if lv_active %}open{% endif %}" style="list-style:none;padding-left:10px;margin:0;">
|
<ul class="nav-group-children {% if lv_active %}open{% endif %}" style="list-style:none;padding-left:10px;margin:0;">
|
||||||
<li class="nav-item">
|
|
||||||
<a href="{{ url_for('main.devices') }}" class="nav-link {% if request.endpoint in ['main.devices','main.device_edit','main.device_detail'] %}active{% endif %}">
|
|
||||||
<i class="fas fa-satellite-dish"></i>Device Health
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a href="{{ url_for('main.logs') }}" class="nav-link {% if request.endpoint == 'main.logs' %}active{% endif %}">
|
<a href="{{ url_for('main.logs') }}" class="nav-link {% if request.endpoint == 'main.logs' %}active{% endif %}">
|
||||||
<i class="fas fa-list-alt"></i>Logs
|
<i class="fas fa-list-alt"></i>Logs
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Devices – {{ app_name }}{% endblock %}
|
{% block title %}Devices – {{ app_name }}{% endblock %}
|
||||||
{% block page_title %}Device Health{% endblock %}
|
{% block page_title %}Devices{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@@ -70,8 +70,8 @@ body.dark-mode .sync-never { background: #2a2a2a; color: #999; }
|
|||||||
<div class="col-6 col-md-2">
|
<div class="col-6 col-md-2">
|
||||||
<div class="card text-center h-100">
|
<div class="card text-center h-100">
|
||||||
<div class="card-body py-3">
|
<div class="card-body py-3">
|
||||||
<h4 class="mb-0 text-info">{{ devices|selectattr('mac_address')|list|length }}</h4>
|
<h4 class="mb-0 text-info">{{ devices|selectattr('wmt_enabled')|list|length }}</h4>
|
||||||
<small class="text-muted">WMT Clients</small>
|
<small class="text-muted">WMT Enabled</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,6 +92,9 @@ body.dark-mode .sync-never { background: #2a2a2a; color: #999; }
|
|||||||
<button class="btn btn-primary ms-auto" data-bs-toggle="modal" data-bs-target="#addDeviceModal">
|
<button class="btn btn-primary ms-auto" data-bs-toggle="modal" data-bs-target="#addDeviceModal">
|
||||||
<i class="fas fa-plus me-1"></i>Add Device
|
<i class="fas fa-plus me-1"></i>Add Device
|
||||||
</button>
|
</button>
|
||||||
|
<a href="{{ url_for('wmt_web.devices_export_csv') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-file-csv me-1"></i>Export CSV
|
||||||
|
</a>
|
||||||
<button class="btn btn-outline-secondary" onclick="location.reload()">
|
<button class="btn btn-outline-secondary" onclick="location.reload()">
|
||||||
<i class="fas fa-sync-alt me-1"></i>Refresh
|
<i class="fas fa-sync-alt me-1"></i>Refresh
|
||||||
</button>
|
</button>
|
||||||
@@ -108,7 +111,9 @@ body.dark-mode .sync-never { background: #2a2a2a; color: #999; }
|
|||||||
<th>Hostname</th>
|
<th>Hostname</th>
|
||||||
<th>IP</th>
|
<th>IP</th>
|
||||||
<th>MAC / Type</th>
|
<th>MAC / Type</th>
|
||||||
|
<th class="text-center">WMT</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Card</th>
|
||||||
<th>Logs</th>
|
<th>Logs</th>
|
||||||
<th>Last Seen</th>
|
<th>Last Seen</th>
|
||||||
<th>Config Sync</th>
|
<th>Config Sync</th>
|
||||||
@@ -117,8 +122,9 @@ body.dark-mode .sync-never { background: #2a2a2a; color: #999; }
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for device in devices %}
|
{% for device in devices %}
|
||||||
{% set is_wmt = device.mac_address is not none and device.mac_address != '' %}
|
{% set has_mac = device.mac_address is not none and device.mac_address != '' %}
|
||||||
{% set has_pending = is_wmt and (pending_by_mac.get(device.mac_address, 0) > 0) %}
|
{% set is_wmt = device.wmt_enabled %}
|
||||||
|
{% set has_pending = has_mac and (pending_by_mac.get(device.mac_address, 0) > 0) %}
|
||||||
<tr class="device-row"
|
<tr class="device-row"
|
||||||
data-search="{{ device.hostname|lower }} {{ device.device_ip }} {{ (device.nume_masa or '')|lower }} {{ (device.mac_address or '')|lower }}">
|
data-search="{{ device.hostname|lower }} {{ device.device_ip }} {{ (device.nume_masa or '')|lower }} {{ (device.mac_address or '')|lower }}">
|
||||||
|
|
||||||
@@ -138,13 +144,27 @@ body.dark-mode .sync-never { background: #2a2a2a; color: #999; }
|
|||||||
|
|
||||||
<!-- MAC / Type -->
|
<!-- MAC / Type -->
|
||||||
<td>
|
<td>
|
||||||
{% if is_wmt %}
|
{% if has_mac %}
|
||||||
<code class="mac-badge">{{ device.mac_address }}</code>
|
<code class="mac-badge">{{ device.mac_address }}</code>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
<span class="text-muted">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<!-- WMT checkpoint -->
|
||||||
|
<td class="text-center">
|
||||||
|
{% if is_wmt %}
|
||||||
|
<span class="text-success"
|
||||||
|
title="WMT enabled · last client check-in {{ device.wmt_last_seen | local_dt('%Y-%m-%d %H:%M:%S') }}">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted" title="No WMT client check-in recorded">
|
||||||
|
<i class="far fa-circle"></i>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<td>
|
<td>
|
||||||
{% if device.status == 'active' %}
|
{% if device.status == 'active' %}
|
||||||
@@ -156,6 +176,19 @@ body.dark-mode .sync-never { background: #2a2a2a; color: #999; }
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<!-- Card presence (WMT) -->
|
||||||
|
<td>
|
||||||
|
{% if is_wmt or has_mac %}
|
||||||
|
{% if device.card_presence == 'enable' %}
|
||||||
|
<span class="badge bg-success">enable</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">disable</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
<!-- Logs -->
|
<!-- Logs -->
|
||||||
<td>{{ device_log_counts.get(device.id, 0) }}</td>
|
<td>{{ device_log_counts.get(device.id, 0) }}</td>
|
||||||
|
|
||||||
@@ -168,7 +201,7 @@ body.dark-mode .sync-never { background: #2a2a2a; color: #999; }
|
|||||||
|
|
||||||
<!-- Config Sync status -->
|
<!-- Config Sync status -->
|
||||||
<td>
|
<td>
|
||||||
{% if not is_wmt %}
|
{% if not has_mac %}
|
||||||
<span class="text-muted">—</span>
|
<span class="text-muted">—</span>
|
||||||
|
|
||||||
{% elif has_pending %}
|
{% elif has_pending %}
|
||||||
@@ -217,6 +250,12 @@ body.dark-mode .sync-never { background: #2a2a2a; color: #999; }
|
|||||||
class="btn btn-sm btn-outline-secondary py-0" title="Edit">
|
class="btn btn-sm btn-outline-secondary py-0" title="Edit">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</a>
|
</a>
|
||||||
|
{% if is_wmt or has_mac %}
|
||||||
|
<a href="{{ url_for('wmt_web.device_edit', device_id=device.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-info py-0" title="WMT Config">
|
||||||
|
<i class="fas fa-tablet-alt"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
<form method="post" action="{{ url_for('main.device_delete', device_id=device.id) }}"
|
<form method="post" action="{{ url_for('main.device_delete', device_id=device.id) }}"
|
||||||
class="d-inline"
|
class="d-inline"
|
||||||
onsubmit="return confirm('Delete device {{ device.hostname }}? This also removes all its logs.')">
|
onsubmit="return confirm('Delete device {{ device.hostname }}? This also removes all its logs.')">
|
||||||
@@ -228,7 +267,7 @@ body.dark-mode .sync-never { background: #2a2a2a; color: #999; }
|
|||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="9" class="text-center text-muted py-5">
|
<td colspan="11" class="text-center text-muted py-5">
|
||||||
<i class="fas fa-desktop fa-2x mb-2 d-block"></i>
|
<i class="fas fa-desktop fa-2x mb-2 d-block"></i>
|
||||||
No devices registered yet.
|
No devices registered yet.
|
||||||
</td>
|
</td>
|
||||||
@@ -242,7 +281,9 @@ body.dark-mode .sync-never { background: #2a2a2a; color: #999; }
|
|||||||
|
|
||||||
<!-- Sync legend -->
|
<!-- Sync legend -->
|
||||||
<div class="d-flex flex-wrap gap-3 mt-3 ms-1">
|
<div class="d-flex flex-wrap gap-3 mt-3 ms-1">
|
||||||
<small class="text-muted"><strong>Config Sync legend:</strong></small>
|
<small class="text-muted"><strong>Legend:</strong></small>
|
||||||
|
<small><span class="text-success"><i class="fas fa-check-circle"></i></span> WMT enabled — device has checked in via the WMT client</small>
|
||||||
|
<small><span class="text-muted"><i class="far fa-circle"></i></span> No WMT check-in recorded</small>
|
||||||
<small><span class="sync-pill sync-ok"><i class="fas fa-check-circle"></i> OK</span> — client confirmed configs match</small>
|
<small><span class="sync-pill sync-ok"><i class="fas fa-check-circle"></i> OK</span> — client confirmed configs match</small>
|
||||||
<small><span class="sync-pill sync-required"><i class="fas fa-arrow-circle-down"></i> Awaiting client</span> — server pushed new config, waiting for client check-in</small>
|
<small><span class="sync-pill sync-required"><i class="fas fa-arrow-circle-down"></i> Awaiting client</span> — server pushed new config, waiting for client check-in</small>
|
||||||
<small><span class="sync-pill sync-pending"><i class="fas fa-user-clock"></i> Pending Approval</span> — new/unknown device waiting for admin</small>
|
<small><span class="sync-pill sync-pending"><i class="fas fa-user-clock"></i> Pending Approval</span> — new/unknown device waiting for admin</small>
|
||||||
|
|||||||
@@ -1 +1,20 @@
|
|||||||
source venv/bin/activate && python3 main.py
|
source venv/bin/activate && python3 main.py
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
eroare:
|
||||||
|
|
||||||
|
INFO:werkzeug:10.76.157.123 - - [27/May/2026 12:08:07] "POST /api/wmt/config/update_request HTTP/1.1" 200 -
|
||||||
|
INFO:werkzeug:10.76.10.24 - - [27/May/2026 12:08:15] "GET / HTTP/1.1" 302 -
|
||||||
|
INFO:werkzeug:10.76.10.24 - - [27/May/2026 12:08:16] "GET /devices HTTP/1.1" 200 -
|
||||||
|
INFO:werkzeug:10.76.10.24 - - [27/May/2026 12:08:17] "GET /nice%20ports,/Trinity.txt.bak HTTP/1.0" 404 -
|
||||||
|
INFO:werkzeug:10.76.10.24 - - [27/May/2026 12:08:17] "GET / HTTP/1.0" 302 -
|
||||||
|
INFO:werkzeug:10.76.10.24 - - [27/May/2026 12:08:17] "OPTIONS / HTTP/1.0" 200 -
|
||||||
|
ERROR:werkzeug:10.76.10.24 - - [27/May/2026 12:08:17] code 400, message Bad request version ('RTSP/1.0')
|
||||||
|
INFO:werkzeug:10.76.10.24 - - [27/May/2026 12:08:17] "OPTIONS / RTSP/1.0" 400 -
|
||||||
|
ERROR:werkzeug:10.76.10.24 - - [27/May/2026 12:08:22] code 400, message Bad HTTP/0.9 request type ('l\x00')
|
||||||
|
INFO:werkzeug:10.76.10.24 - - [27/May/2026 12:08:22] "l\x00\x0b\x00\x00\x00\x00\x00\x00\x00\x00\x00" 400 -
|
||||||
|
ERROR:werkzeug:10.76.10.24 - - [27/May/2026 12:08:22] code 400, message Bad request syntax ('\x16\x03\x00\x00S\x01\x00\x00O\x03\x00?G×÷º,îê²`~ó\x00ý\x82{¹Õ\x96Èw\x9bæÄÛ<=Ûoï\x10n\x00\x00(\x00\x16\x00\x13\x00')
|
||||||
|
INFO:werkzeug:10.76.10.24 - - [27/May/2026 12:08:22] "\x16\x03\x00\x00S\x01\x00\x00O\x03\x00?G×÷º,îê²`~ó\x00ý\x82{¹Õ\x96Èw\x9bæÄÛ<=Ûoï\x10n\x00\x00(\x00\x16\x00\x13\x00" 400 -
|
||||||
|
ERROR:werkzeug:10.76.10.24 - - [27/May/2026 12:08:22] code 400, message Bad request version ('\x00/\x00')
|
||||||
|
INFO:werkzeug:10.76.10.2
|
||||||
Reference in New Issue
Block a user