feat: execution failure reports, auto-printer for WMT, UTC timezone fix for all timestamps
This commit is contained in:
11
ansible/ansible.cfg
Normal file
11
ansible/ansible.cfg
Normal 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
49
ansible/ssh_keys/app_key
Normal file
@@ -0,0 +1,49 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAACFwAAAAdzc2gtcn
|
||||
NhAAAAAwEAAQAAAgEA5e+hvZjALgvbF3RnGdcJ9s6E1C7gwKY20V2GEXb6Uckk9ezM+/am
|
||||
2roETSQ1fNR1ydEY7bbi2kG2zuAWcyfTilGK8BKa1n81g+DarCf37WHSYcNVLslLOnPl1o
|
||||
6Q7SfkCCFnNSvRBWMGZN++WpHUholr+t18OEWTHw1lwxgsOME0V7jOLDedEhsWMsHU3x/R
|
||||
zghunx4Wxa+OHz81qBOefbCi34CyZ1DUlOhxNy3oI95YQoX4aaTOnW15+LUsSmS53Ks6zd
|
||||
CK9xcA6ktHje9hNBYQhkWQn8TnYRkDb2QMpkljUi0oFrsihiF7eAX4PqYW+tMFd/ZPcWmL
|
||||
2W+IJfX36bX1PpwIIB/BWQ+vA3ijCaXmMoYRK1dS6CBJv28nh1xYxoVXmQDbEV+phcv4yv
|
||||
929Ooiijo+Lr+gUdAZTXWdzzJkNkcn0Zpgoq+s47OVUVF+0Z/0ksP1hLz1mhBKxrjKvaUr
|
||||
CIgxGoEvPzS1+9/EfbD1Yzl469RX8J5hCYVAkJ0/iPWWe5Ma6T0vre7M3k+ySXCzkwrnK0
|
||||
qZfiUlhJyNVo4SzIsRB9uU5Ks/qeHI4ZPBIzbCrjn0JZVOWMgE8LYlEFgtD/uL77Wvqivp
|
||||
1Z3g6i1ViACOiCEW2vvTfARxn6XUTCeuzwaQxJYChL30R4+H9juOwxs4XRSJJ4k6376pRY
|
||||
8AAAdQgRXFN4EVxTcAAAAHc3NoLXJzYQAAAgEA5e+hvZjALgvbF3RnGdcJ9s6E1C7gwKY2
|
||||
0V2GEXb6Uckk9ezM+/am2roETSQ1fNR1ydEY7bbi2kG2zuAWcyfTilGK8BKa1n81g+DarC
|
||||
f37WHSYcNVLslLOnPl1o6Q7SfkCCFnNSvRBWMGZN++WpHUholr+t18OEWTHw1lwxgsOME0
|
||||
V7jOLDedEhsWMsHU3x/Rzghunx4Wxa+OHz81qBOefbCi34CyZ1DUlOhxNy3oI95YQoX4aa
|
||||
TOnW15+LUsSmS53Ks6zdCK9xcA6ktHje9hNBYQhkWQn8TnYRkDb2QMpkljUi0oFrsihiF7
|
||||
eAX4PqYW+tMFd/ZPcWmL2W+IJfX36bX1PpwIIB/BWQ+vA3ijCaXmMoYRK1dS6CBJv28nh1
|
||||
xYxoVXmQDbEV+phcv4yv929Ooiijo+Lr+gUdAZTXWdzzJkNkcn0Zpgoq+s47OVUVF+0Z/0
|
||||
ksP1hLz1mhBKxrjKvaUrCIgxGoEvPzS1+9/EfbD1Yzl469RX8J5hCYVAkJ0/iPWWe5Ma6T
|
||||
0vre7M3k+ySXCzkwrnK0qZfiUlhJyNVo4SzIsRB9uU5Ks/qeHI4ZPBIzbCrjn0JZVOWMgE
|
||||
8LYlEFgtD/uL77Wvqivp1Z3g6i1ViACOiCEW2vvTfARxn6XUTCeuzwaQxJYChL30R4+H9j
|
||||
uOwxs4XRSJJ4k6376pRY8AAAADAQABAAACADFFgc+qeVgEo1ypzWQMn+56v5zmNLQjifCg
|
||||
TVfVunsnEpv+M8i0SHnrTXuoTCvlLR5jh6d8vqzNUxqOi1D+0kY8Bf0+x146YSHS35jvca
|
||||
G1Cgt3+3tsmAm9Bx3MbALdvu/9FGwg6Qfx+c9I4LXwtO1lajWWG4XFZurLCKjfN66rvAcm
|
||||
K0vvWOGl20JiJbbwTxmK1gWTwYZ4AYjxlxJereI6JRSms9QOzpbgHk6YMDvra9dJdPtSXR
|
||||
IAARiJ3iVM40UFjjLHQtgC3mfWXM1t49Lw/XaAVqbd30T/wqwucMV7SWS1F3eTfyjl6NrF
|
||||
0LXACoGSEYRszAY9+0FLNI4J4KcUlgE0k6O9zRATxNRvgfZesJj60BqmKgGbGpSPRI8FCS
|
||||
6bhvcAiluqNrJVqcB5sMMeNab+bzeryqkyeU+04/+IN5Uwr4RZ+2LeGyCNHhLNWmRQdkEf
|
||||
4Pl0bpYAISfMNCIJtiFm+H9qUmTynbRUS6Gw4RrifygIrKEYyLpBGobYtEh9vxwZVksHg9
|
||||
0JF3ty6MQ4HHhHIsdaANtYCjtuuTmbtqIIh8QPHQ7bm6mQOmmUtebM0zPzGNWg+mqNG0zZ
|
||||
RZj2gVhapy8U/AzdtuIWWNYxkUfvJH+ISH+hfkUIogb6Onb9FP6P44+HCUTaJ6IcO35YRd
|
||||
C9Ab5HqiisdTtfVph9AAABAQD0Dxssll7RdLVaZk5tgXSF0KW8uaMg/do8bBwqdVTr7JcF
|
||||
eEp1d7tcXX3fs9txLg5BP63CKOmkeRMAq8LrcxXx5kC+RGyUYifRfpUAuB72NiakBkaQ9q
|
||||
9kLKzz4xa5UlCW2c9ZOuPv6jWSamwJifgt4YBFM0cFCcETnUFF+SL/ehz2vBkrHpieoDDe
|
||||
liVrL7BmM5z4oSYYmihA3K9bM0gdGP1T3e4gVZtTepu548VMioyYNpJDL0tCHI9FEqxUqv
|
||||
U/83oFy0zEsnjx7yg4sWwn6agY7ZhIAHxU7tCbHXo0stERTUarINPTmaxoPnK7222pkkOa
|
||||
dPkZiDCMapAW+IesAAABAQD6a4kGBlXGixy2IgwwWFxnyZndTjZ5fAcHX0/3/4mHZDtYl1
|
||||
u0wTP5gANKbuQuun9cA501LMpc+ya6W0XFJneIopFcqsBIvaWF8CptbmA35GiSlwFKpq1b
|
||||
dDkBaxhYU5TY+vpDPKXMqPJoyswO9yf4WnBaOFoc1H674Q+NfFV1csjHngtJWZm3LG/xti
|
||||
mYZ9DXTEricVAZ7KiCwOMKyfF4cdhcqUdBtB9Lv7s8bNWq/+eRSsrnQuqLOudUY7AYjWJs
|
||||
YwLzDdCeEMtlf//G8rDnJ8Zy2hyOhZmVkcRbF1xu79setwwdECgEA3aRz3YzVgZq7UrLa9
|
||||
jm5AskEpH00pwlAAABAQDrD0ANclu3ogbHEpcngjITOnh+40lVz6ouI99LYtRp5r3bYAHz
|
||||
0fczdeODu9QaskEg/M5ys+4Dwb4Zlqbl+6aEpigNELj0FwBhb6z5XCBn4mctYjS7I/XhPF
|
||||
shNZia0Wg4KiEUdVG8aYLozMXG8N0SYMi3Km3vrvGQEjyZImBEAcaIKC7ya+2XXmL3lUwH
|
||||
RFysrV/q76wXFjqLE6PNoj+UTFR5L0JB8FbR3lsOZVsfE+kn2gmUk3xbiCB+9kO2leXT50
|
||||
+mv2eTvWzD/leSWqrxelFyNRrsBqlQfjXKT0S2sotrtwuoSR+zCkutwLeWMGB06IIq8vvK
|
||||
QIadJAFAnFKjAAAAF3NlcnZlcl9tb25pdG9yaXphcmVfYXBwAQID
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
1
ansible/ssh_keys/app_key.pub
Normal file
1
ansible/ssh_keys/app_key.pub
Normal 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
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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})>")
|
||||
@@ -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:
|
||||
|
||||
@@ -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=[])
|
||||
@@ -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">
|
||||
|
||||
@@ -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 & 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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ❌ ${data.failed_hosts||0} failed ⚠️ ${data.unreachable_hosts||0} unreachable`;
|
||||
}
|
||||
const terminal = document.getElementById('liveTerminal');
|
||||
const colorised = (data.log || '')
|
||||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
144
templates/ansible/failure_reports.html
Normal file
144
templates/ansible/failure_reports.html
Normal 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 %}
|
||||
392
templates/ansible/live_popup.html
Normal file
392
templates/ansible/live_popup.html
Normal 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>` +
|
||||
` <span style="color:#f44747">✗ ${data.failed_hosts||0} failed</span>` +
|
||||
` <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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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> | ` +
|
||||
`<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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}">
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) }}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user