feat: execution failure reports, auto-printer for WMT, UTC timezone fix for all timestamps

This commit is contained in:
ske087
2026-04-24 15:52:12 +03:00
parent d2485e4c66
commit 056f467791
27 changed files with 1391 additions and 285 deletions

11
ansible/ansible.cfg Normal file
View File

@@ -0,0 +1,11 @@
[defaults]
remote_user = pi
host_key_checking = False
deprecation_warnings = False
private_key_file = ssh_keys/app_key
roles_path = roles
retry_files_enabled = False
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no
pipelining = True

49
ansible/ssh_keys/app_key Normal file
View File

@@ -0,0 +1,49 @@
-----BEGIN OPENSSH PRIVATE KEY-----
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
-----END OPENSSH PRIVATE KEY-----

View File

@@ -0,0 +1 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDl76G9mMAuC9sXdGcZ1wn2zoTULuDApjbRXYYRdvpRyST17Mz79qbaugRNJDV81HXJ0RjttuLaQbbO4BZzJ9OKUYrwEprWfzWD4NqsJ/ftYdJhw1UuyUs6c+XWjpDtJ+QIIWc1K9EFYwZk375akdSGiWv63Xw4RZMfDWXDGCw4wTRXuM4sN50SGxYywdTfH9HOCG6fHhbFr44fPzWoE559sKLfgLJnUNSU6HE3Legj3lhChfhppM6dbXn4tSxKZLncqzrN0Ir3FwDqS0eN72E0FhCGRZCfxOdhGQNvZAymSWNSLSgWuyKGIXt4Bfg+phb60wV39k9xaYvZb4gl9ffptfU+nAggH8FZD68DeKMJpeYyhhErV1LoIEm/byeHXFjGhVeZANsRX6mFy/jK/3b06iKKOj4uv6BR0BlNdZ3PMmQ2RyfRmmCir6zjs5VRUX7Rn/SSw/WEvPWaEErGuMq9pSsIiDEagS8/NLX738R9sPVjOXjr1FfwnmEJhUCQnT+I9ZZ7kxrpPS+t7szeT7JJcLOTCucrSpl+JSWEnI1WjhLMixEH25Tkqz+p4cjhk8EjNsKuOfQllU5YyATwtiUQWC0P+4vvta+qK+nVneDqLVWIAI6IIRba+9N8BHGfpdRMJ67PBpDElgKEvfRHj4f2O47DGzhdFIkniTrfvqlFjw== server_monitorizare_app

View File

@@ -29,6 +29,9 @@ def create_app(config_name=None):
# Register blueprints
_register_blueprints(app)
# Register template filters
_register_template_filters(app)
# Register error handlers
_register_error_handlers(app)
@@ -104,6 +107,28 @@ def _register_blueprints(app):
from app.api.logs import submit_log
return submit_log()
def _register_template_filters(app):
"""Register custom Jinja2 template filters."""
import calendar
from datetime import datetime as _dt
@app.template_filter('local_dt')
def local_dt_filter(value, fmt='%Y-%m-%d %H:%M'):
"""Convert a naive UTC datetime to server local time and format it.
Usage: {{ some_utc_datetime | local_dt }}
{{ some_utc_datetime | local_dt('%Y-%m-%d %H:%M:%S') }}
"""
if value is None:
return None
try:
# calendar.timegm treats the timetuple as UTC → returns POSIX timestamp
# datetime.fromtimestamp then converts to local time using the system timezone
ts = calendar.timegm(value.timetuple())
return _dt.fromtimestamp(ts).strftime(fmt)
except Exception:
return value.strftime(fmt)
def _register_error_handlers(app):
"""Register error handlers"""

View File

@@ -6,7 +6,7 @@ import json
import os
from datetime import datetime
from app.services.ansible_service import AnsibleService
from app.models import Device, AnsibleExecution
from app.models import Device, AnsibleExecution, ExecutionFailureReport
from config.database_config import get_db
import logging
@@ -148,7 +148,12 @@ def list_playbooks():
'name': 'restart_service',
'description': 'Restart monitoring services on devices',
'builtin': True
}
},
{
'name': 'distribute_ssh_keys',
'description': 'Push server public key to devices using password auth',
'builtin': True
},
]
return jsonify({
@@ -353,6 +358,88 @@ def test_ssh_connectivity():
'success': False
}), 500
@ansible_bp.route('/ssh/test-password', methods=['POST'])
def test_password_auth():
"""
Test password-only SSH authentication to a single device.
Use this to verify the configured password is correct before deploying keys.
Expected JSON:
{
"device_ip": "10.76.157.145",
"password": "raspberry", # optional — uses saved setting if omitted
"username": "pi", # optional
"port": 22 # optional
}
"""
try:
data = request.get_json() or {}
device_ip = (data.get('device_ip') or '').strip()
if not device_ip:
return jsonify({'success': False, 'error': 'device_ip is required'}), 400
# Use provided password, fall back to saved setting
password = data.get('password') or ansible_service.load_settings().get('ssh_fallback_password', '')
if not password:
return jsonify({'success': False,
'error': 'No password provided and none saved in SSH Settings'}), 400
username = data.get('username', 'pi')
port = int(data.get('port', 22))
result = ansible_service.test_password_auth(device_ip, password, username, port)
status = 200 if result.get('success') else 400
return jsonify(result), status
except Exception as e:
logging.error(f"Error testing password auth: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@ansible_bp.route('/ssh/distribute-keys', methods=['POST'])
def distribute_ssh_keys():
"""
Push the server's public SSH key to all (or selected) devices using password auth.
After this completes, all other playbooks can use key-based authentication.
Optional JSON body:
{
"limit_hosts": ["RPI-ABC1", "RPI-ABC2"] # omit to target all devices
}
"""
try:
settings = ansible_service.load_settings()
if not settings.get('ssh_fallback_password'):
return jsonify({
'success': False,
'error': 'No SSH password configured. Set it in SSH Settings before distributing keys.'
}), 400
# Make sure the public key exists
public_key_path = ansible_service.ssh_key_path.with_suffix('.pub')
if not public_key_path.exists():
return jsonify({
'success': False,
'error': 'Public key not found at ansible/ssh_keys/app_key.pub. Generate SSH keys first.'
}), 400
data = request.get_json() or {}
limit_hosts = data.get('limit_hosts') or None
ansible_service.create_distribute_ssh_keys_playbook()
result = ansible_service.execute_playbook_async(
playbook_name='distribute_ssh_keys',
limit_hosts=limit_hosts,
force_password_auth=True,
)
return jsonify(result), 200 if result.get('success') else 500
except Exception as e:
logging.error(f"Error distributing SSH keys: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@ansible_bp.route('/ssh/keys/setup', methods=['POST'])
def setup_ssh_keys():
"""Setup SSH keys for Ansible authentication"""
@@ -452,3 +539,118 @@ def service_restart_callback():
except Exception as e:
logging.error(f"Error in service restart callback: {e}")
return jsonify({'error': str(e)}), 500
# ── Failure Reports ────────────────────────────────────────────────────── #
@ansible_bp.route('/failure-reports', methods=['GET'])
def list_failure_reports():
"""Return all saved execution failure reports, newest first."""
try:
with get_db().get_session() as session:
reports = session.query(ExecutionFailureReport)\
.order_by(ExecutionFailureReport.saved_at.desc())\
.all()
return jsonify({'success': True, 'reports': [r.to_dict() for r in reports]})
except Exception as e:
logging.error(f"Error listing failure reports: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@ansible_bp.route('/failure-reports', methods=['POST'])
def save_failure_report():
"""
Save a failure report for an execution.
Parses the PLAY RECAP from the execution log to extract per-host failure reasons.
Expected JSON:
{
"execution_id": "uuid",
"note": "optional free-text note" # optional
}
"""
import re
try:
data = request.get_json() or {}
execution_id = (data.get('execution_id') or '').strip()
if not execution_id:
return jsonify({'success': False, 'error': 'execution_id is required'}), 400
with get_db().get_session() as session:
from app.models import PlaybookExecution
execution = session.query(PlaybookExecution).filter_by(
execution_id=execution_id
).first()
if not execution:
return jsonify({'success': False, 'error': 'Execution not found'}), 404
# Parse PLAY RECAP for per-host stats
log_text = execution.stdout_log or ''
recap_re = re.compile(
r'^(\S.*?)\s*:\s*ok=(\d+)\s+changed=(\d+)\s+unreachable=(\d+)\s+failed=(\d+)',
re.MULTILINE
)
failed_hosts = []
failed_count = 0
unreachable_count = 0
for m in recap_re.finditer(log_text):
hostname = m.group(1).strip()
unreachable = int(m.group(4))
failed = int(m.group(5))
if unreachable > 0:
failed_hosts.append({'hostname': hostname, 'status': 'unreachable',
'reason': 'Host unreachable via SSH'})
unreachable_count += 1
elif failed > 0:
failed_hosts.append({'hostname': hostname, 'status': 'failed',
'reason': f'{failed} task(s) failed'})
failed_count += 1
if not failed_hosts:
return jsonify({'success': False,
'error': 'No failed or unreachable hosts found in this execution'}), 400
# Avoid duplicate reports for the same execution
existing = session.query(ExecutionFailureReport).filter_by(
execution_id=execution_id
).first()
if existing:
return jsonify({'success': False,
'error': 'A report for this execution already exists',
'report_id': existing.id}), 409
report = ExecutionFailureReport(
execution_id=execution_id,
playbook_name=execution.playbook_name,
saved_at=datetime.utcnow(),
failed_count=failed_count,
unreachable_count=unreachable_count,
failed_hosts=json.dumps(failed_hosts),
note=data.get('note', ''),
)
session.add(report)
session.flush()
report_id = report.id
return jsonify({'success': True, 'report_id': report_id,
'failed_count': failed_count,
'unreachable_count': unreachable_count}), 201
except Exception as e:
logging.error(f"Error saving failure report: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@ansible_bp.route('/failure-reports/<int:report_id>', methods=['DELETE'])
def delete_failure_report(report_id):
"""Delete a saved failure report."""
try:
with get_db().get_session() as session:
report = session.query(ExecutionFailureReport).get(report_id)
if not report:
return jsonify({'success': False, 'error': 'Report not found'}), 404
session.delete(report)
return jsonify({'success': True})
except Exception as e:
logging.error(f"Error deleting failure report {report_id}: {e}")
return jsonify({'success': False, 'error': str(e)}), 500

View File

@@ -26,7 +26,7 @@ __all__ = [
'Base', 'Device', 'MessageTemplate', 'LogEntry', 'FileUpload',
'AnsibleExecution', 'SystemStats', 'InventoryGroup', 'PlaybookExecution',
'PlaybookHostResult', 'ExecutionQueue', 'device_inventory_association',
'WMTGlobalConfig', 'WMTUpdateRequest',
'WMTGlobalConfig', 'WMTUpdateRequest', 'ExecutionFailureReport',
]
class Device(Base):
@@ -627,3 +627,47 @@ class WMTUpdateRequest(Base):
def __repr__(self):
return f"<WMTUpdateRequest(mac='{self.mac_address}', status='{self.status}')>"
class ExecutionFailureReport(Base):
"""Saved report of failed/unreachable hosts from a playbook execution."""
__tablename__ = 'execution_failure_reports'
id = Column(Integer, primary_key=True)
execution_id = Column(String(36), nullable=False, index=True)
playbook_name = Column(String(255), nullable=False)
saved_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Counts
failed_count = Column(Integer, default=0)
unreachable_count = Column(Integer, default=0)
# JSON list of {hostname, status, reason} objects
failed_hosts = Column(Text, nullable=False, default='[]')
# Optional note added by user when saving
note = Column(Text)
@property
def hosts_list(self):
try:
return json.loads(self.failed_hosts)
except Exception:
return []
def to_dict(self):
return {
'id': self.id,
'execution_id': self.execution_id,
'playbook_name': self.playbook_name,
'saved_at': self.saved_at.isoformat() if self.saved_at else None,
'failed_count': self.failed_count,
'unreachable_count': self.unreachable_count,
'failed_hosts': self.hosts_list,
'note': self.note,
}
def __repr__(self):
return (f"<ExecutionFailureReport(execution_id='{self.execution_id}', "
f"playbook='{self.playbook_name}', failed={self.failed_count}, "
f"unreachable={self.unreachable_count})>")

View File

@@ -23,6 +23,7 @@ class AnsibleService:
SETTINGS_FILE = Path("data/ansible_settings.json")
DEFAULT_SETTINGS = {
"ssh_fallback_password": "raspberry",
"use_password_auth": False,
}
def __init__(self):
@@ -30,13 +31,16 @@ class AnsibleService:
self.ansible_dir = Path("ansible")
self.inventory_file = self.ansible_dir / "inventory" / "dynamic_inventory.yaml"
self.playbook_dir = self.ansible_dir / "playbooks"
self.ssh_key_path = Path.home() / ".ssh" / "ansible_key"
self.ssh_keys_dir = self.ansible_dir / "ssh_keys"
self.ssh_key_path = self.ssh_keys_dir / "app_key"
self.ansible_cfg_path = self.ansible_dir / "ansible.cfg"
# Ensure directories exist
self.ansible_dir.mkdir(exist_ok=True)
(self.ansible_dir / "inventory").mkdir(exist_ok=True)
(self.ansible_dir / "playbooks").mkdir(exist_ok=True)
(self.ansible_dir / "roles").mkdir(exist_ok=True)
self.ssh_keys_dir.mkdir(mode=0o700, exist_ok=True)
# ------------------------------------------------------------------ #
# Settings helpers #
@@ -136,10 +140,22 @@ class AnsibleService:
'ansible_host': '127.0.0.1'
}
else:
settings = self.load_settings()
use_password = settings.get('use_password_auth', False)
ssh_password = settings.get('ssh_fallback_password', '')
if use_password and ssh_password:
hvars = {
'ansible_host': device.device_ip,
'ansible_user': 'pi',
'ansible_ssh_private_key_file': str(self.ssh_key_path),
'ansible_password': ssh_password,
'ansible_become_password': ssh_password,
'ansible_ssh_common_args': '-o PubkeyAuthentication=no -o PreferredAuthentications=password -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'
}
else:
hvars = {
'ansible_host': device.device_ip,
'ansible_user': 'pi',
'ansible_ssh_private_key_file': str(self.ssh_key_path.resolve()),
'ansible_ssh_common_args': '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'
}
children['monitoring_devices']['hosts'][device.hostname] = hvars
@@ -249,7 +265,7 @@ class AnsibleService:
'name': 'Update monitoring devices',
'hosts': 'all',
'become': True,
'gather_facts': True,
'gather_facts': False,
'tasks': [
{
'name': 'Update apt cache',
@@ -268,31 +284,15 @@ class AnsibleService:
'register': 'upgrade_result'
},
{
'name': 'Restart device if required',
'reboot': {
'reboot_timeout': 600
},
'when': 'upgrade_result.changed'
},
{
'name': 'Check service status',
'systemd': {
'name': 'prezenta.service',
'state': 'started'
'name': 'Show upgrade result',
'debug': {
'msg': '{{ upgrade_result.stdout_lines }}'
}
},
{
'name': 'Report update completion',
'uri': {
'url': 'http://{{ ansible_controller_ip }}/api/update_complete',
'method': 'POST',
'body_format': 'json',
'body': {
'hostname': '{{ inventory_hostname }}',
'device_ip': '{{ ansible_host }}',
'status': 'completed',
'packages_updated': '{{ upgrade_result.stdout_lines | length }}'
}
'name': 'Clean up apt cache',
'apt': {
'autoclean': True
}
}
]
@@ -391,6 +391,17 @@ class AnsibleService:
if extra_vars:
cmd.extend(['--extra-vars', json.dumps(extra_vars)])
# Inject password auth vars if enabled (overrides per-host inventory vars)
settings = self.load_settings()
if settings.get('use_password_auth') and settings.get('ssh_fallback_password'):
pwd = settings['ssh_fallback_password']
cmd.extend(['--extra-vars', json.dumps({
'ansible_password': pwd,
'ansible_become_password': pwd,
'ansible_ssh_private_key_file': '',
'ansible_ssh_common_args': '-o PubkeyAuthentication=no -o PreferredAuthentications=password -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'
})])
# Create enhanced execution record using new model
execution_id = str(uuid.uuid4())
with self.db.get_session() as session:
@@ -416,12 +427,19 @@ class AnsibleService:
with tempfile.NamedTemporaryFile(mode='w+', suffix='.log', delete=False) as log_file:
log_file_path = log_file.name
env = os.environ.copy()
env['PYTHONUNBUFFERED'] = '1'
env['ANSIBLE_FORCE_COLOR'] = '0'
env['ANSIBLE_NOCOLOR'] = '1'
env['ANSIBLE_CONFIG'] = str(self.ansible_cfg_path.resolve())
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=str(self.ansible_dir)
cwd=str(self.ansible_dir),
env=env,
)
stdout, stderr = process.communicate()
@@ -435,11 +453,12 @@ class AnsibleService:
execution.stderr_log = stderr
execution.ansible_log_file = log_file_path
# Always parse recap stats regardless of exit code —
# Ansible exits non-zero when any host fails/is unreachable.
self._parse_ansible_results_enhanced(execution, stdout)
if process.returncode == 0:
execution.status = 'completed'
execution.summary_message = 'Playbook executed successfully'
# Parse stdout for success/failure counts
self._parse_ansible_results_enhanced(execution, stdout)
else:
execution.status = 'failed'
execution.summary_message = f'Playbook failed with exit code {process.returncode}'
@@ -474,10 +493,14 @@ class AnsibleService:
def execute_playbook_async(self, playbook_name: str, limit_hosts: List[str] = None,
extra_vars: Dict = None, priority: int = 5,
max_retries: int = 0) -> Dict:
max_retries: int = 0,
force_password_auth: bool = False) -> Dict:
"""
Start a playbook in a background thread.
Returns immediately with the execution_id so the caller can poll /live.
force_password_auth=True overrides the use_password_auth setting and always
injects password vars — used by distribute_ssh_keys which must run before
keys are deployed.
"""
try:
self.generate_dynamic_inventory()
@@ -498,6 +521,17 @@ class AnsibleService:
# Pass all extra vars as a single JSON string to avoid value-quoting issues
cmd.extend(['--extra-vars', json.dumps(extra_vars)])
# Inject password auth vars if enabled OR forced
settings = self.load_settings()
if (force_password_auth or settings.get('use_password_auth')) and settings.get('ssh_fallback_password'):
pwd = settings['ssh_fallback_password']
cmd.extend(['--extra-vars', json.dumps({
'ansible_password': pwd,
'ansible_become_password': pwd,
'ansible_ssh_private_key_file': '',
'ansible_ssh_common_args': '-o PubkeyAuthentication=no -o PreferredAuthentications=password -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'
})])
# Create a persistent log file (NOT deleted on close)
log_fd, log_file_path = tempfile.mkstemp(suffix='.log', prefix='ansible_')
os.close(log_fd)
@@ -546,6 +580,7 @@ class AnsibleService:
env['PYTHONUNBUFFERED'] = '1'
env['ANSIBLE_FORCE_COLOR'] = '0'
env['ANSIBLE_NOCOLOR'] = '1'
env['ANSIBLE_CONFIG'] = str(self.ansible_cfg_path.resolve())
process = subprocess.Popen(
cmd,
@@ -586,10 +621,12 @@ class AnsibleService:
execution.completed_at = datetime.utcnow()
execution.exit_code = process.returncode
execution.stdout_log = full_output
# Always parse recap stats regardless of exit code —
# Ansible exits non-zero when any host fails/is unreachable.
self._parse_ansible_results_enhanced(execution, full_output)
if process.returncode == 0:
execution.status = 'completed'
execution.summary_message = 'Playbook executed successfully'
self._parse_ansible_results_enhanced(execution, full_output)
else:
execution.status = 'failed'
execution.summary_message = f'Playbook failed (exit {process.returncode})'
@@ -653,47 +690,39 @@ class AnsibleService:
return {'success': False, 'error': str(e)}
def _parse_ansible_results_enhanced(self, execution: PlaybookExecution, output: str):
"""Parse Ansible output for enhanced result statistics"""
lines = output.split('\n')
"""Parse Ansible PLAY RECAP output for result statistics."""
import re
successful_hosts = 0
failed_hosts = 0
unreachable_hosts = 0
skipped_hosts = 0
changed_hosts = 0
for line in lines:
if 'ok=' in line and 'changed=' in line:
# Parse line like: "host1: ok=4 changed=2 unreachable=0 failed=0"
try:
if 'failed=0' in line:
successful_hosts += 1
else:
failed_count = int(line.split('failed=')[1].split()[0])
if failed_count > 0:
# Match PLAY RECAP lines:
# "RPI-FOO : ok=4 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0"
recap_re = re.compile(
r'ok=(\d+)\s+changed=(\d+)\s+unreachable=(\d+)\s+failed=(\d+)'
)
for line in output.split('\n'):
m = recap_re.search(line)
if not m:
continue
ok = int(m.group(1))
changed = int(m.group(2))
unreachable = int(m.group(3))
failed = int(m.group(4))
if unreachable > 0:
unreachable_hosts += 1
elif failed > 0:
failed_hosts += 1
else:
successful_hosts += 1
if 'unreachable=' in line:
unreachable = int(line.split('unreachable=')[1].split()[0])
if unreachable > 0:
unreachable_hosts += 1
if 'skipped=' in line:
skipped = int(line.split('skipped=')[1].split()[0])
if skipped > 0:
skipped_hosts += 1
if 'changed=' in line:
changed = int(line.split('changed=')[1].split()[0])
if changed > 0:
changed_hosts += 1
except (ValueError, IndexError):
# Skip malformed lines
continue
# Update execution record
execution.successful_hosts = successful_hosts
execution.failed_hosts = failed_hosts
execution.unreachable_hosts = unreachable_hosts
@@ -706,10 +735,18 @@ class AnsibleService:
'update_devices': 'Update all packages and monitoring software on devices',
'restart_service': 'Restart monitoring services on selected devices',
'system_health': 'Check system health and monitoring status',
'maintenance_mode': 'Put devices in maintenance mode'
'maintenance_mode': 'Put devices in maintenance mode',
'distribute_ssh_keys': 'Push server public key to all devices using password auth',
}
return descriptions.get(playbook_name, f'Execute {playbook_name} playbook')
def create_distribute_ssh_keys_playbook(self) -> str:
"""Ensure the distribute_ssh_keys playbook file exists (ships with the repo)."""
playbook_path = self.playbook_dir / 'distribute_ssh_keys.yml'
if not playbook_path.exists():
logging.warning('distribute_ssh_keys.yml not found — playbook file is missing')
return str(playbook_path)
def create_system_health_playbook(self) -> str:
"""Create system health check playbook"""
playbook_content = {
@@ -782,6 +819,53 @@ class AnsibleService:
unreachable = int(line.split('unreachable=')[1].split()[0])
execution.unreachable_hosts += unreachable
def test_password_auth(self, device_ip: str, password: str,
username: str = 'pi', port: int = 22) -> Dict:
"""
Test SSH connectivity using password-only authentication (no key fallback).
Uses sshpass so we can confirm the exact password works before deploying keys.
"""
try:
# Quick TCP reachability check first
import socket
with socket.create_connection((device_ip, port), timeout=5):
pass
except (OSError, ConnectionRefusedError) as e:
return {'success': False, 'reachable': False,
'error': f'Host unreachable on port {port}: {e}'}
try:
result = subprocess.run(
[
'sshpass', '-p', password,
'ssh',
'-o', 'PubkeyAuthentication=no',
'-o', 'PreferredAuthentications=password',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', f'ConnectTimeout=8',
'-p', str(port),
f'{username}@{device_ip}',
'echo OK',
],
capture_output=True, text=True, timeout=15,
)
if result.returncode == 0 and 'OK' in result.stdout:
return {'success': True, 'reachable': True,
'message': f'Password authentication succeeded for {username}@{device_ip}'}
else:
stderr = (result.stderr or '').strip()
return {'success': False, 'reachable': True,
'error': f'Authentication failed — {stderr or "wrong password"}'}
except subprocess.TimeoutExpired:
return {'success': False, 'reachable': True,
'error': 'SSH command timed out'}
except FileNotFoundError:
return {'success': False, 'reachable': True,
'error': 'sshpass not installed — run: sudo apt-get install sshpass'}
except Exception as e:
return {'success': False, 'reachable': True, 'error': str(e)}
def test_ssh_connectivity(self, device_ip: str, username: str = 'pi') -> Dict:
"""Test SSH connectivity to a device"""
try:

View File

@@ -3,7 +3,7 @@ Web routes for Ansible management interface
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from app.services.ansible_service import AnsibleService
from app.models import Device, AnsibleExecution, PlaybookExecution
from app.models import Device, AnsibleExecution, PlaybookExecution, ExecutionFailureReport
from config.database_config import get_db
import logging
@@ -92,7 +92,12 @@ def playbooks():
'name': 'restart_service',
'description': 'Restart monitoring services on devices',
'builtin': True
}
},
{
'name': 'distribute_ssh_keys',
'description': 'Push server public key to devices using password auth',
'builtin': True
},
]
return render_template('ansible/playbooks.html',
@@ -123,17 +128,20 @@ def execute():
})
seen.add(h['hostname'])
settings = ansible_service.load_settings()
return render_template('ansible/execute.html',
inventory=inventory_data,
all_inv_hosts=all_inv_hosts,
preselect_playbook=preselect)
preselect_playbook=preselect,
use_password_auth=settings.get('use_password_auth', False))
except Exception as e:
logging.error(f"Error loading execute form: {e}")
flash(f'Error loading form: {e}', 'error')
return render_template('ansible/execute.html',
inventory={'groups': {}},
all_inv_hosts=[],
preselect_playbook='')
preselect_playbook='',
use_password_auth=False)
elif request.method == 'POST':
# Execute playbook
@@ -182,17 +190,24 @@ def execute():
ansible_service.create_restart_service_playbook()
elif playbook_name == 'system_health':
ansible_service.create_system_health_playbook()
elif playbook_name == 'distribute_ssh_keys':
ansible_service.create_distribute_ssh_keys_playbook()
# Add controller IP for callbacks
extra_vars['ansible_controller_ip'] = request.host
# Force password auth for key distribution, or honour the form toggle
force_password = (playbook_name == 'distribute_ssh_keys') or \
bool(request.form.get('force_password_auth'))
# Use async execution (returns immediately with execution_id)
result = ansible_service.execute_playbook_async(
playbook_name=playbook_name,
limit_hosts=selected_hosts,
extra_vars=extra_vars,
priority=priority,
max_retries=max_retries
max_retries=max_retries,
force_password_auth=force_password,
)
if result['success']:
@@ -256,6 +271,11 @@ def execution_details(execution_id):
flash(f'Error loading execution details: {e}', 'error')
return redirect(url_for('ansible_web.executions'))
@ansible_web_bp.route('/executions/<execution_id>/live-popup')
def execution_live_popup(execution_id):
"""Standalone popup window for live execution output"""
return render_template('ansible/live_popup.html', execution_id=execution_id)
@ansible_web_bp.route('/ssh/setup')
def ssh_setup():
"""SSH key setup interface"""
@@ -288,10 +308,14 @@ def save_ssh_settings():
try:
fallback_password = request.form.get('ssh_fallback_password', '').strip()
if not fallback_password:
flash('Fallback password cannot be empty.', 'error')
flash('Password cannot be empty.', 'error')
return redirect(url_for('ansible_web.ssh_setup'))
ansible_service.save_settings({'ssh_fallback_password': fallback_password})
use_password_auth = request.form.get('use_password_auth') == 'on'
ansible_service.save_settings({
'ssh_fallback_password': fallback_password,
'use_password_auth': use_password_auth,
})
flash('SSH settings saved successfully.', 'success')
except Exception as e:
logging.error(f"Error saving SSH settings: {e}")
@@ -597,3 +621,19 @@ def delete_playbook():
except Exception as e:
logging.error(f"Error deleting playbook: {e}")
return jsonify({'error': str(e)}), 500
@ansible_web_bp.route('/failure-reports')
def failure_reports():
"""View all saved execution failure reports."""
try:
with get_db().get_session() as session:
reports = session.query(ExecutionFailureReport)\
.order_by(ExecutionFailureReport.saved_at.desc())\
.all()
reports_data = [r.to_dict() for r in reports]
return render_template('ansible/failure_reports.html', reports=reports_data)
except Exception as e:
logging.error(f"Error loading failure reports: {e}")
flash(f'Error loading failure reports: {e}', 'error')
return render_template('ansible/failure_reports.html', reports=[])

View File

@@ -319,7 +319,7 @@
<span class="badge bg-danger">Offline</span>
{% endif %}
</td>
<td>{{ device.last_check.strftime('%Y-%m-%d %H:%M') if device.last_check else 'Never' }}</td>
<td>{{ device.last_check | local_dt if device.last_check else 'Never' }}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="testDevice('{{ device.name }}')">
<i class="fas fa-plug"></i>
@@ -482,7 +482,7 @@
</div>
<p class="card-text text-muted small">
<i class="fas fa-clock"></i>
{{ execution.start_time.strftime('%Y-%m-%d %H:%M:%S') if execution.start_time else 'N/A' }}
{{ execution.start_time | local_dt('%Y-%m-%d %H:%M:%S') if execution.start_time else 'N/A' }}
</p>
<div class="row text-center">
<div class="col">

View File

@@ -21,9 +21,11 @@
{% for g in inventory.groups.values() %}{% set ns.total = ns.total + g.hosts|length %}{% endfor %}
<span class="badge bg-primary ms-2">{{ ns.total }}</span>
</span>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-success" onclick="syncDevices()">
<i class="fas fa-sync-alt me-1"></i>Sync All
<div class="d-flex align-items-center gap-1">
<span id="lastSyncedLabel" class="text-muted me-1" style="font-size:.72rem;"></span>
<button class="btn btn-sm btn-success" onclick="syncDevices()" id="syncBtn"
title="Pull latest hostnames &amp; IPs from the monitoring database">
<i class="fas fa-database me-1"></i>Sync IPs from DB
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="toggleRaw()" title="Raw YAML">
<i class="fas fa-code"></i>
@@ -87,7 +89,7 @@
{% else %}
<div class="card-body text-center py-5 text-muted">
<i class="fas fa-box-open fa-3x mb-3"></i>
<p class="mb-0">Inventory is empty. Use <strong>Sync All</strong> or add from below.</p>
<p class="mb-0">Inventory is empty. Click <strong>Sync IPs from DB</strong> to import all active devices.</p>
</div>
{% endif %}
</div>
@@ -414,22 +416,24 @@ function togglePwField(val) {
document.getElementById('pwField').classList.toggle('d-none', val !== 'password');
}
/* ── Sync all DB devices into monitoring_devices ── */
/* ── Sync IPs from monitoring DB into inventory ── */
async function syncDevices() {
const btn = event.currentTarget;
const btn = document.getElementById('syncBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Syncing…';
try {
const r = await fetch(`${API}/inventory/sync`, {method:'POST'});
const d = await r.json();
if (d.success) {
const now = new Date().toLocaleTimeString();
document.getElementById('lastSyncedLabel').textContent = `Last synced: ${now}`;
showAlert(`<i class="fas fa-check-circle me-1"></i> ${d.message}`, 'success');
setTimeout(() => location.reload(), 1200);
} else { showAlert(`Sync failed: ${d.error}`, 'danger'); }
} catch { showAlert('Network error', 'danger'); }
finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-sync-alt me-1"></i>Sync All';
btn.innerHTML = '<i class="fas fa-database me-1"></i>Sync IPs from DB';
}
}

View File

@@ -11,24 +11,7 @@
.playbook-card:hover { border-color: #0d6efd; box-shadow: 0 3px 10px rgba(13,110,253,.15); }
.playbook-card.selected { border-color: #198754; background-color: #f8fff9; }
.json-editor { font-family: 'Courier New', monospace; background-color: #f8f9fa; }
#liveCard { display: none; }
#liveTerminal {
background: #1e1e1e; color: #d4d4d4;
font-family: 'Courier New', monospace; font-size: .78rem;
line-height: 1.5; height: 380px; overflow-y: auto;
white-space: pre-wrap; word-break: break-all;
border-radius: 0 0 8px 8px; padding: 12px 16px;
}
#liveTerminal .ansi-ok { color: #4ec9b0; }
#liveTerminal .ansi-changed { color: #dcdcaa; }
#liveTerminal .ansi-fail { color: #f44747; }
#liveTerminal .ansi-unreachable { color: #ce9178; }
#liveTerminal .ansi-task { color: #9cdcfe; font-weight: bold; }
#liveTerminal .ansi-play { color: #c586c0; font-weight: bold; }
.live-header {
background: #252526; color: #ccc; border-radius: 8px 8px 0 0;
padding: 8px 16px; font-size: .8rem; display: flex; align-items: center; gap: 10px;
}
#execPopupNotice { display: none; }
.pulse-dot {
width: 10px; height: 10px; border-radius: 50%;
background: #4ec9b0; animation: pulse 1.2s infinite; display: inline-block;
@@ -93,6 +76,17 @@
</div>
</div>
</div>
<div class="card playbook-card mb-3" data-name="distribute_ssh_keys" onclick="selectPlaybook('distribute_ssh_keys')">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1 text-warning"><i class="fas fa-key me-1"></i>Distribute SSH Keys</h6>
<p class="small text-muted mb-0">Push server public key to devices using password auth</p>
</div>
<span class="badge bg-warning text-dark ms-2 flex-shrink-0">Built-in</span>
</div>
</div>
</div>
<h6 class="text-muted fw-bold mb-2 small text-uppercase">Custom Playbooks</h6>
<select class="form-select" id="customPlaybook">
@@ -240,10 +234,22 @@
<option value="3">3 retries</option>
</select>
</div>
<div class="form-check">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="checkMode" name="check_mode">
<label class="form-check-label" for="checkMode">Dry run (check mode)</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="forcePasswordAuth"
name="force_password_auth" role="switch"
{% if use_password_auth %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="forcePasswordAuth">
<i class="fas fa-lock me-1 text-warning"></i>Use password authentication
</label>
<div class="text-muted small mt-1">
Override SSH key auth and connect with the configured device password.
Auto-enabled for <em>Distribute SSH Keys</em>.
</div>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Extra Variables (JSON)</label>
@@ -300,38 +306,22 @@
</form>
<!-- ── Live Execution Output ──────────────────────────────────── -->
<div class="row mt-3" id="liveCard">
<!-- ── Popup launched notice ─────────────────────────────────── -->
<div class="row mt-3" id="execPopupNotice">
<div class="col-12">
<div class="card shadow">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<span class="fw-semibold">
<i class="fas fa-terminal me-2"></i>Live Execution Output
</span>
<div class="d-flex align-items-center gap-3">
<span id="liveStatusBadge" class="badge bg-secondary">Waiting…</span>
<a id="liveDetailsLink" href="#" class="btn btn-sm btn-outline-primary" style="display:none">
<i class="fas fa-external-link-alt me-1"></i>Full Details
<div class="alert alert-info d-flex align-items-center gap-3 mb-0">
<span class="pulse-dot" id="noticePulseDot"></span>
<div class="flex-grow-1">
<strong>Execution started!</strong>
A live output window has been opened.
If it was blocked by your browser, use the link below.
</div>
<a id="noticePopupLink" href="#" target="_blank" class="btn btn-sm btn-outline-primary flex-shrink-0">
<i class="fas fa-external-link-alt me-1"></i>Open Live Output
</a>
<a id="noticeDetailsLink" href="#" class="btn btn-sm btn-outline-secondary flex-shrink-0">
<i class="fas fa-list me-1"></i>Full Details
</a>
</div>
</div>
<div class="live-header">
<span class="pulse-dot" id="livePulseDot"></span>
<span id="liveStatusText">Initializing…</span>
<span class="ms-auto text-muted" id="liveElapsed"></span>
</div>
<div id="liveTerminal"></div>
<div class="card-footer d-flex justify-content-between align-items-center py-2">
<small class="text-muted" id="liveHostSummary"></small>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" onclick="toggleAutoScroll()">
<i class="fas fa-arrow-down" id="autoScrollIcon"></i> Auto-scroll
</button>
<button class="btn btn-sm btn-outline-danger" id="liveStopBtn" onclick="stopPolling()">
<i class="fas fa-stop"></i> Stop polling
</button>
</div>
</div>
</div>
</div>
</div>
@@ -342,12 +332,7 @@
// ── State ────────────────────────────────────────────────────────────
let selectedPlaybook = null;
let targetMode = 'all';
// Live output state
let pollTimer = null;
let executionId = null;
let autoScroll = true;
let pollStartTime = null;
// ── Playbook selection ───────────────────────────────────────────────
function selectPlaybook(name) {
@@ -356,6 +341,15 @@ function selectPlaybook(name) {
document.querySelector(`.playbook-card[data-name="${name}"]`)?.classList.add('selected');
selectedPlaybook = name;
document.getElementById('selectedPlaybook').value = name;
// Auto-enable password auth for the key distribution playbook
const pwdToggle = document.getElementById('forcePasswordAuth');
if (pwdToggle) {
if (name === 'distribute_ssh_keys') {
pwdToggle.checked = true;
}
}
updateSummary();
}
@@ -485,9 +479,6 @@ document.getElementById('executeForm').addEventListener('submit', function(e) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting…';
resetLiveCard();
document.getElementById('liveCard').style.display = '';
fetch(this.action, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
@@ -495,112 +486,35 @@ document.getElementById('executeForm').addEventListener('submit', function(e) {
})
.then(r => r.json())
.then(data => {
if (data.success) {
executionId = data.execution_id;
pollStartTime = Date.now();
const link = document.getElementById('liveDetailsLink');
link.href = `/ansible/executions/${executionId}`;
link.style.display = '';
startPolling();
} else {
setLiveError(data.error || 'Unknown error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play me-1"></i>Execute';
if (data.success) {
executionId = data.execution_id;
const popupUrl = `/ansible/executions/${executionId}/live-popup`;
const detailUrl = `/ansible/executions/${executionId}`;
// Open independent popup window
window.open(popupUrl, `exec_${executionId}`,
'width=960,height=660,resizable=yes,scrollbars=no,toolbar=no,menubar=no');
// Show notice bar with fallback links
const notice = document.getElementById('execPopupNotice');
notice.style.display = '';
document.getElementById('noticePopupLink').href = popupUrl;
document.getElementById('noticeDetailsLink').href = detailUrl;
document.getElementById('noticePulseDot').className = 'pulse-dot';
} else {
alert('Execution failed: ' + (data.error || 'Unknown error'));
}
})
.catch(err => {
setLiveError('Network error: ' + err);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play me-1"></i>Execute';
alert('Network error: ' + err);
});
});
// ── Live terminal ────────────────────────────────────────────────────
function resetLiveCard() {
document.getElementById('liveTerminal').textContent = '';
document.getElementById('liveStatusBadge').className = 'badge bg-secondary';
document.getElementById('liveStatusBadge').textContent = 'Starting…';
document.getElementById('liveStatusText').textContent = 'Initializing…';
document.getElementById('livePulseDot').className = 'pulse-dot';
document.getElementById('liveHostSummary').textContent = '';
document.getElementById('liveElapsed').textContent = '';
document.getElementById('liveDetailsLink').style.display = 'none';
document.getElementById('liveStopBtn').style.display = '';
}
function startPolling() {
pollTimer = setInterval(pollLive, 2000);
pollLive();
}
function stopPolling() {
clearInterval(pollTimer);
pollTimer = null;
document.getElementById('liveStopBtn').style.display = 'none';
document.getElementById('liveStatusText').textContent += ' (polling stopped)';
}
function pollLive() {
if (!executionId) return;
fetch(`/api/ansible/executions/${executionId}/live`)
.then(r => r.json())
.then(data => {
if (!data.success) { setLiveError(data.error); return; }
renderLiveData(data);
if (['completed','failed','cancelled','timeout'].includes(data.status)) {
stopPolling();
document.getElementById('liveStopBtn').style.display = 'none';
document.getElementById('executeButton').disabled = false;
document.getElementById('executeButton').innerHTML = '<i class="fas fa-play me-1"></i>Execute';
}
})
.catch(err => console.warn('Poll error:', err));
}
function renderLiveData(data) {
const badge = document.getElementById('liveStatusBadge');
const dot = document.getElementById('livePulseDot');
const colors = { running:'bg-primary', completed:'bg-success', failed:'bg-danger',
cancelled:'bg-warning', timeout:'bg-warning' };
badge.className = 'badge ' + (colors[data.status] || 'bg-secondary');
badge.textContent = data.status.charAt(0).toUpperCase() + data.status.slice(1);
dot.className = data.status === 'running' ? 'pulse-dot' :
data.status === 'completed' ? 'pulse-dot done' : 'pulse-dot error';
document.getElementById('liveStatusText').textContent =
data.summary_message || `Running ${data.playbook_name} on ${(data.target_hosts||[]).join(', ')}`;
if (pollStartTime) {
const sec = Math.round((Date.now() - pollStartTime) / 1000);
document.getElementById('liveElapsed').textContent = `${sec}s elapsed`;
}
if (data.status !== 'running') {
document.getElementById('liveHostSummary').innerHTML =
`${data.successful_hosts||0} ok &nbsp; ❌ ${data.failed_hosts||0} failed &nbsp; ⚠️ ${data.unreachable_hosts||0} unreachable`;
}
const terminal = document.getElementById('liveTerminal');
const colorised = (data.log || '')
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/(PLAY\s+\[.*?\])/g, '<span class="ansi-play">$1</span>')
.replace(/(TASK\s+\[.*?\])/g, '<span class="ansi-task">$1</span>')
.replace(/(ok:.*)/g, '<span class="ansi-ok">$1</span>')
.replace(/(changed:.*)/g, '<span class="ansi-changed">$1</span>')
.replace(/(fatal:.*)/g, '<span class="ansi-fail">$1</span>')
.replace(/(FAILED!.*)/g, '<span class="ansi-fail">$1</span>')
.replace(/(UNREACHABLE!.*)/g, '<span class="ansi-unreachable">$1</span>');
terminal.innerHTML = colorised;
if (autoScroll) terminal.scrollTop = terminal.scrollHeight;
}
function setLiveError(msg) {
document.getElementById('liveStatusBadge').className = 'badge bg-danger';
document.getElementById('liveStatusBadge').textContent = 'Error';
document.getElementById('livePulseDot').className = 'pulse-dot error';
document.getElementById('liveTerminal').textContent = 'Error: ' + msg;
}
function toggleAutoScroll() {
autoScroll = !autoScroll;
document.getElementById('autoScrollIcon').style.opacity = autoScroll ? '1' : '0.3';
}
// ── Initialize ───────────────────────────────────────────────────────
updateTargetCount();

View File

@@ -141,7 +141,7 @@
<div class="row">
<div class="col-6">
<small class="text-muted">Started:</small><br>
{{ execution.started_at.strftime('%Y-%m-%d %H:%M') if execution.started_at else 'Queued' }}
{{ execution.started_at | local_dt if execution.started_at else 'Queued' }}
</div>
<div class="col-6">
<small class="text-muted">Duration:</small><br>

View File

@@ -0,0 +1,144 @@
{% extends "base.html" %}
{% block title %}Execution Failure Reports — Server Monitoring{% endblock %}
{% block page_title %}Execution Failure Reports{% endblock %}
{% block content %}
<div class="container-fluid">
<div id="alertArea"></div>
<!-- Header row -->
<div class="d-flex justify-content-between align-items-center mb-3">
<p class="text-muted mb-0">
Saved records of failed or unreachable hosts from completed playbook executions.
</p>
<span class="badge bg-secondary fs-6">{{ reports | length }} report(s)</span>
</div>
{% if reports %}
<div class="row g-3" id="reportsList">
{% for report in reports %}
<div class="col-12" id="report-{{ report.id }}">
<div class="card shadow-sm border-{% if report.unreachable_count > 0 and report.failed_count == 0 %}warning{% elif report.failed_count > 0 %}danger{% else %}secondary{% endif %}">
<!-- Card header -->
<div class="card-header d-flex justify-content-between align-items-start flex-wrap gap-2 py-2">
<div>
<i class="fas fa-exclamation-triangle me-2 text-{% if report.failed_count > 0 %}danger{% else %}warning{% endif %}"></i>
<strong>{{ report.playbook_name }}</strong>
<span class="text-muted ms-2" style="font-size:.82rem;">
Execution <code>{{ report.execution_id[:8] }}…</code>
</span>
</div>
<div class="d-flex align-items-center gap-2">
{% if report.failed_count > 0 %}
<span class="badge bg-danger"><i class="fas fa-times-circle me-1"></i>{{ report.failed_count }} failed</span>
{% endif %}
{% if report.unreachable_count > 0 %}
<span class="badge bg-warning text-dark"><i class="fas fa-plug me-1"></i>{{ report.unreachable_count }} unreachable</span>
{% endif %}
<span class="text-muted" style="font-size:.78rem;">
<i class="fas fa-calendar-alt me-1"></i>{{ report.saved_at[:19].replace('T',' ') }}
</span>
<a href="/ansible/executions/{{ report.execution_id }}" class="btn btn-sm btn-outline-secondary" target="_blank"
title="View full execution">
<i class="fas fa-external-link-alt"></i>
</a>
<button class="btn btn-sm btn-outline-danger" onclick="deleteReport({{ report.id }})"
title="Delete this report">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<!-- Host list -->
<div class="card-body py-2 px-0">
{% if report.note %}
<div class="px-3 pb-2">
<i class="fas fa-sticky-note text-muted me-1"></i>
<small class="text-muted">{{ report.note }}</small>
</div>
{% endif %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width:50%">Hostname</th>
<th style="width:20%">Status</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{% for host in report.failed_hosts %}
<tr>
<td><strong>{{ host.hostname }}</strong></td>
<td>
{% if host.status == 'unreachable' %}
<span class="badge bg-warning text-dark">unreachable</span>
{% else %}
<span class="badge bg-danger">failed</span>
{% endif %}
</td>
<td class="text-muted" style="font-size:.85rem;">{{ host.reason }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card">
<div class="card-body text-center py-5 text-muted">
<i class="fas fa-check-circle fa-3x mb-3 text-success"></i>
<p class="mb-0">No failure reports saved yet.<br>
Use the <strong>Save Report</strong> button in the execution popup when a playbook has failed or unreachable hosts.
</p>
</div>
</div>
{% endif %}
</div>
<script>
const API = '/api/ansible';
function showAlert(html, type='info') {
const area = document.getElementById('alertArea');
area.innerHTML = `<div class="alert alert-${type} alert-dismissible fade show">
${html}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>`;
setTimeout(() => { if (area.firstChild) area.firstChild.remove(); }, 5000);
}
async function deleteReport(reportId) {
if (!confirm('Delete this failure report?')) return;
try {
const r = await fetch(`${API}/failure-reports/${reportId}`, {method:'DELETE'});
const d = await r.json();
if (d.success) {
document.getElementById(`report-${reportId}`).remove();
showAlert('<i class="fas fa-check-circle me-1"></i>Report deleted.', 'success');
// Show empty state if no more reports
if (!document.querySelector('#reportsList .col-12')) {
document.getElementById('reportsList').innerHTML =
'<div class="col-12"><div class="card"><div class="card-body text-center py-5 text-muted">' +
'<i class="fas fa-check-circle fa-3x mb-3 text-success"></i>' +
'<p class="mb-0">No failure reports saved yet.</p></div></div></div>';
}
} else {
showAlert(`Error: ${d.error}`, 'danger');
}
} catch(e) {
showAlert('Network error: ' + e, 'danger');
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,392 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ansible Execution — Live Output</title>
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
crossorigin="anonymous" referrerpolicy="no-referrer">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #1e1e1e;
color: #d4d4d4;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* ── Top bar ─────────────────────────────────────────────── */
#topBar {
background: #252526;
border-bottom: 1px solid #3c3c3c;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
#topBar .title {
font-size: .85rem;
font-weight: 600;
color: #ccc;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#statusBadge {
font-size: .75rem;
padding: 3px 10px;
border-radius: 12px;
background: #555;
color: #fff;
flex-shrink: 0;
}
#statusBadge.running { background: #0d6efd; }
#statusBadge.completed { background: #198754; }
#statusBadge.failed { background: #dc3545; }
#statusBadge.cancelled { background: #ffc107; color: #000; }
#statusBadge.timeout { background: #ffc107; color: #000; }
.pulse-dot {
width: 9px; height: 9px; border-radius: 50%;
background: #4ec9b0;
animation: pulse 1.2s infinite;
display: inline-block;
flex-shrink: 0;
}
.pulse-dot.done { background: #6a9955; animation: none; }
.pulse-dot.error { background: #f44747; animation: none; }
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:.3;} }
/* ── Info bar ────────────────────────────────────────────── */
#infoBar {
background: #2d2d2d;
border-bottom: 1px solid #3c3c3c;
padding: 6px 16px;
display: flex;
gap: 20px;
flex-wrap: wrap;
font-size: .75rem;
color: #9d9d9d;
flex-shrink: 0;
}
#infoBar span strong { color: #ccc; }
/* ── Terminal ────────────────────────────────────────────── */
#terminal {
flex: 1;
overflow-y: auto;
background: #1e1e1e;
padding: 12px 16px;
font-family: 'Courier New', 'Consolas', monospace;
font-size: .78rem;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-all;
}
#terminal .ansi-ok { color: #4ec9b0; }
#terminal .ansi-changed { color: #dcdcaa; }
#terminal .ansi-fail { color: #f44747; }
#terminal .ansi-unreachable { color: #ce9178; }
#terminal .ansi-task { color: #9cdcfe; font-weight: bold; }
#terminal .ansi-play { color: #c586c0; font-weight: bold; }
#terminal .ansi-recap { color: #569cd6; font-weight: bold; }
#terminal .ansi-skipped { color: #808080; }
#terminal .ansi-warning { color: #ff8c00; }
#placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: #555;
}
#placeholder .spinner {
width: 36px; height: 36px;
border: 3px solid #333;
border-top-color: #4ec9b0;
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Footer ─────────────────────────────────────────────── */
#footer {
background: #252526;
border-top: 1px solid #3c3c3c;
padding: 7px 16px;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
font-size: .75rem;
gap: 10px;
}
#hostSummary { color: #9d9d9d; }
#elapsed { color: #6a9955; }
.btn-sm {
padding: 3px 10px;
font-size: .73rem;
border-radius: 4px;
border: 1px solid #555;
background: transparent;
color: #ccc;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
text-decoration: none;
}
.btn-sm:hover { background: #3c3c3c; }
.btn-sm.danger { border-color: #dc3545; color: #f44747; }
.btn-sm.danger:hover { background: #3c1a1a; }
.btn-sm.primary { border-color: #0d6efd; color: #6ea8fe; }
.btn-sm.primary:hover { background: #1a2a3c; }
.btn-group { display: flex; gap: 6px; }
</style>
</head>
<body>
<!-- ── Top bar ──────────────────────────────────────────────────── -->
<div id="topBar">
<span class="pulse-dot" id="pulseDot"></span>
<span class="title" id="titleText">
<i class="fas fa-terminal" style="color:#4ec9b0;margin-right:6px;"></i>
Connecting to execution <code style="color:#9cdcfe;">{{ execution_id[:8] }}…</code>
</span>
<span id="statusBadge">Waiting…</span>
</div>
<!-- ── Info bar ─────────────────────────────────────────────────── -->
<div id="infoBar">
<span><strong>Playbook:</strong> <span id="infoPlaybook"></span></span>
<span><strong>Hosts:</strong> <span id="infoHosts"></span></span>
<span><strong>Elapsed:</strong> <span id="elapsed">0s</span></span>
<span id="infoExtra"></span>
</div>
<!-- ── Terminal output ─────────────────────────────────────────── -->
<div id="terminal">
<div id="placeholder">
<div class="spinner"></div>
<div>Waiting for execution output…</div>
</div>
</div>
<!-- ── Footer ───────────────────────────────────────────────────── -->
<div id="footer">
<span id="hostSummary"></span>
<div class="btn-group">
<button class="btn-sm" id="autoScrollBtn" onclick="toggleAutoScroll()">
<i class="fas fa-arrow-down" id="autoScrollIcon"></i> Auto-scroll
</button>
<a id="detailsLink" class="btn-sm primary"
href="/ansible/executions/{{ execution_id }}" target="_blank">
<i class="fas fa-external-link-alt"></i> Full Details
</a>
<button class="btn-sm" id="saveReportBtn" onclick="saveFailureReport()"
style="display:none;background:#c0392b;color:#fff;border:none;">
<i class="fas fa-save"></i> Save Report
</button>
<button class="btn-sm danger" id="stopBtn" onclick="stopPolling()">
<i class="fas fa-stop"></i> Stop polling
</button>
</div>
</div>
<script>
const EXECUTION_ID = "{{ execution_id }}";
const API_URL = `/api/ansible/executions/${EXECUTION_ID}/live`;
let pollTimer = null;
let autoScroll = true;
let startTime = Date.now();
let firstOutput = false;
let elapsedInterval = null;
// ── Start immediately ────────────────────────────────────────────
startPolling();
elapsedInterval = setInterval(() => {
const sec = Math.round((Date.now() - startTime) / 1000);
document.getElementById('elapsed').textContent =
sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`;
}, 1000);
function startPolling() {
pollTimer = setInterval(poll, 2000);
poll();
}
function stopPolling() {
clearInterval(pollTimer);
pollTimer = null;
clearInterval(elapsedInterval);
elapsedInterval = null;
document.getElementById('stopBtn').style.display = 'none';
const dot = document.getElementById('pulseDot');
dot.className = 'pulse-dot done';
appendLine('\n— Polling stopped by user —', '#808080');
}
function poll() {
fetch(API_URL)
.then(r => r.json())
.then(data => {
if (!data.success) { renderError(data.error || 'Unknown error'); return; }
renderData(data);
const done = ['completed','failed','cancelled','timeout'].includes(data.status);
if (done) {
clearInterval(pollTimer);
clearInterval(elapsedInterval);
pollTimer = null;
document.getElementById('stopBtn').style.display = 'none';
}
})
.catch(err => appendLine('Poll error: ' + err, '#f44747'));
}
function renderData(data) {
// Title bar
document.getElementById('titleText').innerHTML =
`<i class="fas fa-terminal" style="color:#4ec9b0;margin-right:6px;"></i>`+
`<strong style="color:#9cdcfe;">${data.playbook_name || EXECUTION_ID.slice(0,8)}</strong>`+
`${(data.target_hosts || []).join(', ') || 'all hosts'}`;
// Status badge
const badge = document.getElementById('statusBadge');
badge.textContent = (data.status || 'unknown').charAt(0).toUpperCase() + (data.status||'').slice(1);
badge.className = data.status || '';
// Pulse dot
const dot = document.getElementById('pulseDot');
dot.className = data.status === 'running' ? 'pulse-dot' :
data.status === 'completed' ? 'pulse-dot done' : 'pulse-dot error';
// Info bar
document.getElementById('infoPlaybook').textContent = data.playbook_name || '—';
document.getElementById('infoHosts').textContent = (data.target_hosts || []).join(', ') || '—';
if (data.summary_message) {
document.getElementById('infoExtra').innerHTML =
`<strong>Status:</strong> ${escHtml(data.summary_message)}`;
}
// Host summary (when done)
if (!['running','queued'].includes(data.status)) {
document.getElementById('hostSummary').innerHTML =
`<span style="color:#4ec9b0">✓ ${data.successful_hosts||0} ok</span>` +
` &nbsp; <span style="color:#f44747">✗ ${data.failed_hosts||0} failed</span>` +
` &nbsp; <span style="color:#ce9178">⚠ ${data.unreachable_hosts||0} unreachable</span>`;
// Show "Save Report" button only when there are failures/unreachable hosts
const hasFailures = (data.failed_hosts > 0 || data.unreachable_hosts > 0);
const saveBtn = document.getElementById('saveReportBtn');
if (hasFailures && !window._reportSaved) {
saveBtn.style.display = '';
}
}
// Terminal output
const log = data.log || '';
if (log) {
if (!firstOutput) {
document.getElementById('placeholder').remove();
firstOutput = true;
}
const term = document.getElementById('terminal');
term.innerHTML = colorize(escHtml(log));
if (autoScroll) term.scrollTop = term.scrollHeight;
}
}
function renderError(msg) {
document.getElementById('pulseDot').className = 'pulse-dot error';
document.getElementById('statusBadge').textContent = 'Error';
document.getElementById('statusBadge').className = 'failed';
if (!firstOutput) {
document.getElementById('placeholder').remove();
firstOutput = true;
}
document.getElementById('terminal').innerHTML =
`<span style="color:#f44747">Error: ${escHtml(msg)}</span>`;
}
function appendLine(text, color) {
const term = document.getElementById('terminal');
const span = document.createElement('span');
span.style.color = color || '#d4d4d4';
span.textContent = text + '\n';
term.appendChild(span);
if (autoScroll) term.scrollTop = term.scrollHeight;
}
function colorize(html) {
return html
.replace(/(PLAY\s+\[.*?\](?:\s*\*+)?)/g, '<span class="ansi-play">$1</span>')
.replace(/(TASK\s+\[.*?\](?:\s*\*+)?)/g, '<span class="ansi-task">$1</span>')
.replace(/(PLAY RECAP(?:\s*\*+)?)/g, '<span class="ansi-recap">$1</span>')
.replace(/(ok:\s+\[.*?\].*)/g, '<span class="ansi-ok">$1</span>')
.replace(/(changed:\s+\[.*?\].*)/g, '<span class="ansi-changed">$1</span>')
.replace(/(skipping:\s+\[.*?\].*)/g, '<span class="ansi-skipped">$1</span>')
.replace(/(fatal:.*)/g, '<span class="ansi-fail">$1</span>')
.replace(/(FAILED!.*)/g, '<span class="ansi-fail">$1</span>')
.replace(/(UNREACHABLE!.*)/g, '<span class="ansi-unreachable">$1</span>')
.replace(/(\[WARNING\].*)/g, '<span class="ansi-warning">$1</span>');
}
function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function toggleAutoScroll() {
autoScroll = !autoScroll;
document.getElementById('autoScrollIcon').style.opacity = autoScroll ? '1' : '0.35';
document.getElementById('autoScrollBtn').style.borderColor = autoScroll ? '#555' : '#ffc107';
}
async function saveFailureReport() {
const btn = document.getElementById('saveReportBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving…';
try {
const r = await fetch('/api/ansible/failure-reports', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({execution_id: EXECUTION_ID})
});
const d = await r.json();
if (d.success) {
btn.style.background = '#27ae60';
btn.innerHTML = '<i class="fas fa-check"></i> Saved';
window._reportSaved = true;
} else if (r.status === 409) {
btn.style.background = '#7f8c8d';
btn.innerHTML = '<i class="fas fa-info-circle"></i> Already saved';
window._reportSaved = true;
} else {
btn.disabled = false;
btn.style.background = '#c0392b';
btn.innerHTML = '<i class="fas fa-exclamation-circle"></i> Failed — retry';
appendLine('Error saving report: ' + (d.error || 'unknown'), '#f44747');
}
} catch(e) {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-exclamation-circle"></i> Error';
appendLine('Network error: ' + e, '#f44747');
}
}
// Close popup when parent window unloads (optional: keep open)
// window.addEventListener('beforeunload', stopPolling);
</script>
</body>
</html>

View File

@@ -63,18 +63,18 @@
</div>
<div class="card-body">
<p class="text-muted small">
When key-based authentication fails, the server falls back to password auth.
Set the default password for devices on this network below.
Configure SSH authentication for Ansible. Enable password mode to authenticate
with a username/password instead of SSH keys.
</p>
<form method="post" action="{{ url_for('ansible_web.save_ssh_settings') }}">
<div class="mb-3">
<label class="form-label fw-semibold">SSH Fallback Password</label>
<label class="form-label fw-semibold">SSH Password</label>
<div class="input-group">
<input type="password" name="ssh_fallback_password" id="sshFallbackPassword"
class="form-control"
value="{{ settings.get('ssh_fallback_password', '') }}"
placeholder="Enter fallback password"
placeholder="Enter device password"
required>
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword()">
@@ -86,6 +86,19 @@
</small>
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" name="use_password_auth"
id="usePasswordAuth" role="switch"
{% if settings.get('use_password_auth') %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="usePasswordAuth">
Use password authentication (instead of SSH keys)
</label>
<div class="text-muted small mt-1">
When enabled, Ansible will connect to all devices using the password above.
SSH key files will be ignored.
</div>
</div>
<button type="submit" class="btn btn-success">
<i class="fas fa-save me-1"></i>Save Settings
</button>
@@ -95,6 +108,87 @@
</div>
</div><!-- /row -->
<!-- Test Password row -->
<div class="row g-4 mt-1">
<div class="col-12">
<div class="card shadow-sm border-info">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="fas fa-vial me-2"></i>Test Password Authentication</h5>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Verify the password above works on a specific device <strong>before</strong> running the full key deployment.
This connects with password-only auth (no SSH key) so you get an accurate pre-flight result.
</p>
<div class="row g-2 align-items-end">
<div class="col-sm-5">
<label class="form-label fw-semibold mb-1">Device IP</label>
<input type="text" id="testIpInput" class="form-control"
placeholder="e.g. 10.76.157.145" autocomplete="off">
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold mb-1">Password <span class="text-muted fw-normal">(leave blank to use saved)</span></label>
<input type="password" id="testPasswordInput" class="form-control"
placeholder="Uses saved password if empty" autocomplete="off">
</div>
<div class="col-sm-3">
<button id="testPasswordBtn" class="btn btn-info w-100" onclick="testPasswordAuth()">
<i class="fas fa-plug me-1"></i>Test Connection
</button>
</div>
</div>
<div id="testPasswordResult" class="mt-3" style="display:none;"></div>
</div>
</div>
</div>
</div>
<!-- Deploy SSH Keys row -->
<div class="row g-4 mt-1">
<div class="col-12">
<div class="card shadow-sm border-warning">
<div class="card-header bg-warning text-dark">
<h5 class="mb-0"><i class="fas fa-rocket me-2"></i>Deploy SSH Keys to All Devices</h5>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-lg-8">
<p class="mb-1">
Connects to every device <strong>using the password</strong> configured above
and copies <code>~/.ssh/id_rsa.pub</code> into each device's
<code>~/.ssh/authorized_keys</code>.
</p>
<p class="text-muted small mb-0">
<i class="fas fa-info-circle me-1"></i>
Run this once to bootstrap key-based auth. Afterwards, disable
<em>"Use password authentication"</em> so all playbooks switch to SSH keys automatically.
</p>
</div>
<div class="col-lg-4 text-lg-end mt-3 mt-lg-0">
{% if not settings.get('ssh_fallback_password') %}
<div class="alert alert-warning py-2 mb-2 small">
<i class="fas fa-exclamation-triangle me-1"></i>
Set the SSH Password first, then save settings.
</div>
{% endif %}
<button id="deployKeysBtn" class="btn btn-warning btn-lg"
{% if not settings.get('ssh_fallback_password') %}disabled{% endif %}
onclick="deploySSHKeys()">
<i class="fas fa-key me-1"></i>Deploy SSH Keys to All Devices
</button>
</div>
</div>
<div id="deployStatusBar" class="mt-3" style="display:none;">
<div class="alert alert-info mb-0" id="deployStatusMsg">
<span class="spinner-border spinner-border-sm me-2"></span>Starting deployment…
</div>
</div>
</div>
</div>
</div>
</div>
</div><!-- /container -->
<script>
@@ -109,5 +203,95 @@ function togglePassword() {
icon.classList.replace('fa-eye-slash', 'fa-eye');
}
}
function testPasswordAuth() {
const ip = document.getElementById('testIpInput').value.trim();
const pw = document.getElementById('testPasswordInput').value;
const btn = document.getElementById('testPasswordBtn');
const result = document.getElementById('testPasswordResult');
if (!ip) {
result.style.display = '';
result.innerHTML = '<div class="alert alert-warning py-2 mb-0"><i class="fas fa-exclamation-triangle me-2"></i>Enter a device IP first.</div>';
return;
}
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Testing…';
result.style.display = '';
result.innerHTML = '<div class="alert alert-secondary py-2 mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Connecting to ' + ip + '…</div>';
const body = { device_ip: ip };
if (pw) body.password = pw;
fetch('/api/ansible/ssh/test-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify(body)
})
.then(r => r.json())
.then(data => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-plug me-1"></i>Test Connection';
if (data.success) {
result.innerHTML = '<div class="alert alert-success py-2 mb-0"><i class="fas fa-check-circle me-2"></i>' + (data.message || 'Authentication succeeded!') + '</div>';
} else {
const reachable = data.reachable;
const icon = reachable === false ? 'fa-times-circle' : 'fa-key';
const cls = reachable === false ? 'alert-warning' : 'alert-danger';
result.innerHTML = '<div class="alert ' + cls + ' py-2 mb-0"><i class="fas ' + icon + ' me-2"></i>' + (data.error || 'Connection failed') + '</div>';
}
})
.catch(err => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-plug me-1"></i>Test Connection';
result.innerHTML = '<div class="alert alert-danger py-2 mb-0"><i class="fas fa-times-circle me-2"></i>Network error: ' + err + '</div>';
});
}
function deploySSHKeys() {
const btn = document.getElementById('deployKeysBtn');
const bar = document.getElementById('deployStatusBar');
const msg = document.getElementById('deployStatusMsg');
if (!confirm('Deploy the server SSH public key to ALL devices using the configured password?\n\nThis will add the key to each device\'s authorized_keys file.')) return;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Deploying…';
bar.style.display = '';
msg.className = 'alert alert-info mb-0';
msg.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting deployment…';
fetch('/api/ansible/ssh/distribute-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({})
})
.then(r => r.json())
.then(data => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-key me-1"></i>Deploy SSH Keys to All Devices';
if (data.success) {
const execId = data.execution_id;
const popupUrl = `/ansible/executions/${execId}/live-popup`;
const detailUrl = `/ansible/executions/${execId}`;
window.open(popupUrl, `deploy_keys_${execId}`,
'width=960,height=660,resizable=yes,scrollbars=no,toolbar=no,menubar=no');
msg.className = 'alert alert-success mb-0';
msg.innerHTML = `<i class="fas fa-check-circle me-2"></i>Deployment started. ` +
`<a href="${popupUrl}" target="_blank">Open live output</a> &nbsp;|&nbsp; ` +
`<a href="${detailUrl}">View details</a>`;
} else {
msg.className = 'alert alert-danger mb-0';
msg.innerHTML = `<i class="fas fa-times-circle me-2"></i>Error: ${data.error || 'Unknown error'}`;
}
})
.catch(err => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-key me-1"></i>Deploy SSH Keys to All Devices';
msg.className = 'alert alert-danger mb-0';
msg.innerHTML = `<i class="fas fa-times-circle me-2"></i>Network error: ${err}`;
});
}
</script>
{% endblock %}

View File

@@ -303,6 +303,18 @@
Execute
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('ansible_web.ssh_setup') }}" class="nav-link {% if request.endpoint == 'ansible_web.ssh_setup' %}active{% endif %}">
<i class="fas fa-key"></i>
SSH Setup
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('ansible_web.failure_reports') }}" class="nav-link {% if request.endpoint == 'ansible_web.failure_reports' %}active{% endif %}">
<i class="fas fa-exclamation-triangle"></i>
Failure Reports
</a>
</li>
<div class="nav-section">Server</div>
<li class="nav-item">

View File

@@ -166,7 +166,7 @@
{% endif %}
</td>
<td>
<small>{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') if log.timestamp else 'N/A' }}</small>
<small>{{ log.timestamp | local_dt('%Y-%m-%d %H:%M:%S') if log.timestamp else 'N/A' }}</small>
</td>
<td>
<span class="badge bg-{% if log.severity == 'error' %}danger{% elif log.severity == 'warning' %}warning{% elif log.severity == 'info' %}primary{% else %}secondary{% endif %}">

View File

@@ -99,7 +99,7 @@
{% endif %}
<tr>
<td class="info-label">Last Seen</td>
<td>{{ device.last_seen.strftime('%Y-%m-%d %H:%M:%S') if device.last_seen else '—' }}</td>
<td>{{ device.last_seen | local_dt('%Y-%m-%d %H:%M:%S') if device.last_seen else '—' }}</td>
</tr>
</table>
</div>
@@ -148,13 +148,13 @@
</tr>
<tr>
<td class="info-label">Config Updated</td>
<td>{{ device.config_updated_at.strftime('%Y-%m-%d %H:%M:%S') if device.config_updated_at else 'Never' }}</td>
<td>{{ device.config_updated_at | local_dt('%Y-%m-%d %H:%M:%S') if device.config_updated_at else 'Never' }}</td>
</tr>
<tr>
<td class="info-label">Info Reviewed</td>
<td>
{% if device.info_reviewed_at and device.info_reviewed_at.year > 1970 %}
<span class="text-success">{{ device.info_reviewed_at.strftime('%Y-%m-%d %H:%M:%S') }}</span>
<span class="text-success">{{ device.info_reviewed_at | local_dt('%Y-%m-%d %H:%M:%S') }}</span>
{% else %}
<span class="text-muted">Never reviewed</span>
{% endif %}
@@ -207,7 +207,7 @@
<tbody>
{% for log in logs %}
<tr class="log-row">
<td class="text-nowrap text-muted">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') if log.timestamp else '—' }}</td>
<td class="text-nowrap text-muted">{{ log.timestamp | local_dt('%Y-%m-%d %H:%M:%S') if log.timestamp else '—' }}</td>
<td>
<span class="severity-{{ log.severity }}">
<i class="fas fa-circle fa-xs me-1"></i>{{ log.severity }}

View File

@@ -77,13 +77,13 @@
{% if device.mac_address %}
<div class="alert alert-light border small mb-4">
<strong>Config last updated:</strong>
{{ device.config_updated_at.strftime('%Y-%m-%d %H:%M:%S') if device.config_updated_at else '—' }}<br>
{{ device.config_updated_at | local_dt('%Y-%m-%d %H:%M:%S') if device.config_updated_at else '—' }}<br>
<strong>Info reviewed at:</strong>
<span class="{% if device.info_reviewed_at and device.info_reviewed_at.year > 1970 %}text-success{% else %}text-muted{% endif %}">
{{ device.info_reviewed_at.strftime('%Y-%m-%d %H:%M:%S') if (device.info_reviewed_at and device.info_reviewed_at.year > 1970) else 'Never reviewed' }}
{{ device.info_reviewed_at | local_dt('%Y-%m-%d %H:%M:%S') if (device.info_reviewed_at and device.info_reviewed_at.year > 1970) else 'Never reviewed' }}
</span><br>
<strong>Last seen:</strong>
{{ device.last_seen.strftime('%Y-%m-%d %H:%M:%S') if device.last_seen else 'Never' }}
{{ device.last_seen | local_dt('%Y-%m-%d %H:%M:%S') if device.last_seen else 'Never' }}
</div>
{% endif %}

View File

@@ -124,14 +124,14 @@
<td>{{ device_log_counts.get(device.id, 0) }}</td>
<td class="text-muted">
{% if device.last_seen %}
{{ device.last_seen.strftime('%Y-%m-%d %H:%M') }}
{{ device.last_seen | local_dt }}
{% else %}—{% endif %}
</td>
<td>
{% if device.mac_address and device.config_updated_at %}
<span class="sync-ok" title="{{ device.config_updated_at.strftime('%Y-%m-%d %H:%M:%S') }}">
<span class="sync-ok" title="{{ device.config_updated_at | local_dt('%Y-%m-%d %H:%M:%S') }}">
<i class="fas fa-check-circle"></i>
{{ device.config_updated_at.strftime('%m-%d %H:%M') }}
{{ device.config_updated_at | local_dt('%m-%d %H:%M') }}
</span>
{% elif device.mac_address %}
<span class="sync-old"><i class="fas fa-clock"></i> Never</span>

View File

@@ -163,7 +163,7 @@
</div>
<div class="log-meta mt-2">
<i class="fas fa-clock"></i> {{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') if log.timestamp else 'N/A' }}
<i class="fas fa-clock"></i> {{ log.timestamp | local_dt('%Y-%m-%d %H:%M:%S') if log.timestamp else 'N/A' }}
{% if log.template_hash %}
<span class="ms-3"><i class="fas fa-tag"></i> Template: {{ log.template_hash[:8] }}</span>
{% endif %}

View File

@@ -69,7 +69,7 @@
<td>{{ template.template_text[:80] }}{% if template.template_text | length > 80 %}...{% endif %}</td>
<td><span class="badge bg-secondary">{{ template.category or 'uncategorized' }}</span></td>
<td>{{ template.usage_count }}</td>
<td>{{ template.created_at.strftime('%Y-%m-%d %H:%M') if template.created_at else 'N/A' }}</td>
<td>{{ template.created_at | local_dt if template.created_at else 'N/A' }}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="viewTemplate('{{ template.template_hash }}')">
<i class="fas fa-eye"></i>

View File

@@ -65,12 +65,12 @@
{% if device %}
<div class="alert alert-light border small mb-4">
<strong>Last seen:</strong>
{{ device.last_seen.strftime('%Y-%m-%d %H:%M:%S') if device.last_seen else 'Never' }}<br>
{{ device.last_seen | local_dt('%Y-%m-%d %H:%M:%S') if device.last_seen else 'Never' }}<br>
<strong>Config updated:</strong>
{{ device.config_updated_at.strftime('%Y-%m-%d %H:%M:%S') if device.config_updated_at else '—' }}<br>
{{ device.config_updated_at | local_dt('%Y-%m-%d %H:%M:%S') if device.config_updated_at else '—' }}<br>
<strong>Info reviewed at:</strong>
<span class="{% if device.info_reviewed_at and device.info_reviewed_at.year > 1970 %}text-success{% else %}text-muted{% endif %}">
{{ device.info_reviewed_at.strftime('%Y-%m-%d %H:%M:%S') if (device.info_reviewed_at and device.info_reviewed_at.year > 1970) else 'Never reviewed' }}
{{ device.info_reviewed_at | local_dt('%Y-%m-%d %H:%M:%S') if (device.info_reviewed_at and device.info_reviewed_at.year > 1970) else 'Never reviewed' }}
</span>
<br><small class="text-muted">Updated automatically when you save this form, accept or reject a device request.</small>
</div>

View File

@@ -36,10 +36,10 @@
<td>{{ d.hostname or '—' }}</td>
<td>{{ d.device_ip or '—' }}</td>
<td class="text-muted small">
{{ d.last_seen.strftime('%Y-%m-%d %H:%M') if d.last_seen else 'Never' }}
{{ d.last_seen | local_dt if d.last_seen else 'Never' }}
</td>
<td class="text-muted small">
{{ d.config_updated_at.strftime('%Y-%m-%d %H:%M') if d.config_updated_at else '—' }}
{{ d.config_updated_at | local_dt if d.config_updated_at else '—' }}
</td>
<td class="text-end">
<a href="{{ url_for('wmt_web.device_edit', device_id=d.id) }}"

View File

@@ -71,7 +71,7 @@
<p class="text-muted mb-1 small">Config Last Updated</p>
<h6 class="mb-0">
{% if global_cfg and global_cfg.updated_at %}
{{ global_cfg.updated_at.strftime('%Y-%m-%d %H:%M') }}
{{ global_cfg.updated_at | local_dt }}
{% else %}
Never
{% endif %}
@@ -129,7 +129,7 @@
<td><code>{{ d.mac_address }}</code></td>
<td>{{ d.device_ip or '—' }}</td>
<td class="text-muted small">
{{ d.last_seen.strftime('%Y-%m-%d %H:%M') if d.last_seen else 'Never' }}
{{ d.last_seen | local_dt if d.last_seen else 'Never' }}
</td>
<td>
<a href="{{ url_for('main.device_edit', device_id=d.id) }}"
@@ -165,7 +165,7 @@
<li class="list-group-item d-flex justify-content-between align-items-start py-2">
<div>
<code class="small">{{ r.mac_address }}</code><br>
<small class="text-muted">{{ r.submitted_at.strftime('%Y-%m-%d %H:%M') }}</small>
<small class="text-muted">{{ r.submitted_at | local_dt }}</small>
</div>
<span class="badge badge-{{ r.status }} rounded-pill">{{ r.status }}</span>
</li>

View File

@@ -69,12 +69,12 @@
<td>{{ r.proposed_device_name or '—' }}</td>
<td>{{ r.proposed_hostname or '—' }}</td>
<td>{{ r.proposed_device_ip or '—' }}</td>
<td class="text-muted small">{{ r.submitted_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td class="text-muted small">{{ r.submitted_at | local_dt }}</td>
<td class="text-muted small">{{ r.client_config_mtime or '—' }}</td>
<td>
<span class="badge badge-{{ r.status }} rounded-pill">{{ r.status }}</span>
{% if r.admin_reviewed_at %}
<br><small class="text-muted">{{ r.admin_reviewed_at.strftime('%Y-%m-%d %H:%M') }}</small>
<br><small class="text-muted">{{ r.admin_reviewed_at | local_dt }}</small>
{% endif %}
</td>
{% if status_filter == 'pending' or status_filter == 'all' %}

View File

@@ -14,7 +14,7 @@
<small class="text-muted ms-3">
Applied to all WMT devices on next sync.
{% if cfg and cfg.updated_at %}
Last saved: {{ cfg.updated_at.strftime('%Y-%m-%d %H:%M:%S') }} by {{ cfg.updated_by or 'admin' }}
Last saved: {{ cfg.updated_at | local_dt('%Y-%m-%d %H:%M:%S') }} by {{ cfg.updated_by or 'admin' }}
{% endif %}
</small>
</div>