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
|
||||||
_register_blueprints(app)
|
_register_blueprints(app)
|
||||||
|
|
||||||
|
# Register template filters
|
||||||
|
_register_template_filters(app)
|
||||||
|
|
||||||
# Register error handlers
|
# Register error handlers
|
||||||
_register_error_handlers(app)
|
_register_error_handlers(app)
|
||||||
|
|
||||||
@@ -104,6 +107,28 @@ def _register_blueprints(app):
|
|||||||
from app.api.logs import submit_log
|
from app.api.logs import submit_log
|
||||||
return 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):
|
def _register_error_handlers(app):
|
||||||
"""Register error handlers"""
|
"""Register error handlers"""
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.services.ansible_service import AnsibleService
|
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
|
from config.database_config import get_db
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -148,7 +148,12 @@ def list_playbooks():
|
|||||||
'name': 'restart_service',
|
'name': 'restart_service',
|
||||||
'description': 'Restart monitoring services on devices',
|
'description': 'Restart monitoring services on devices',
|
||||||
'builtin': True
|
'builtin': True
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
'name': 'distribute_ssh_keys',
|
||||||
|
'description': 'Push server public key to devices using password auth',
|
||||||
|
'builtin': True
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -353,6 +358,88 @@ def test_ssh_connectivity():
|
|||||||
'success': False
|
'success': False
|
||||||
}), 500
|
}), 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'])
|
@ansible_bp.route('/ssh/keys/setup', methods=['POST'])
|
||||||
def setup_ssh_keys():
|
def setup_ssh_keys():
|
||||||
"""Setup SSH keys for Ansible authentication"""
|
"""Setup SSH keys for Ansible authentication"""
|
||||||
@@ -451,4 +538,119 @@ def service_restart_callback():
|
|||||||
return jsonify({'success': True})
|
return jsonify({'success': True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error in service restart callback: {e}")
|
logging.error(f"Error in service restart callback: {e}")
|
||||||
return jsonify({'error': str(e)}), 500
|
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',
|
'Base', 'Device', 'MessageTemplate', 'LogEntry', 'FileUpload',
|
||||||
'AnsibleExecution', 'SystemStats', 'InventoryGroup', 'PlaybookExecution',
|
'AnsibleExecution', 'SystemStats', 'InventoryGroup', 'PlaybookExecution',
|
||||||
'PlaybookHostResult', 'ExecutionQueue', 'device_inventory_association',
|
'PlaybookHostResult', 'ExecutionQueue', 'device_inventory_association',
|
||||||
'WMTGlobalConfig', 'WMTUpdateRequest',
|
'WMTGlobalConfig', 'WMTUpdateRequest', 'ExecutionFailureReport',
|
||||||
]
|
]
|
||||||
|
|
||||||
class Device(Base):
|
class Device(Base):
|
||||||
@@ -626,4 +626,48 @@ class WMTUpdateRequest(Base):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<WMTUpdateRequest(mac='{self.mac_address}', status='{self.status}')>"
|
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")
|
SETTINGS_FILE = Path("data/ansible_settings.json")
|
||||||
DEFAULT_SETTINGS = {
|
DEFAULT_SETTINGS = {
|
||||||
"ssh_fallback_password": "raspberry",
|
"ssh_fallback_password": "raspberry",
|
||||||
|
"use_password_auth": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -30,13 +31,16 @@ class AnsibleService:
|
|||||||
self.ansible_dir = Path("ansible")
|
self.ansible_dir = Path("ansible")
|
||||||
self.inventory_file = self.ansible_dir / "inventory" / "dynamic_inventory.yaml"
|
self.inventory_file = self.ansible_dir / "inventory" / "dynamic_inventory.yaml"
|
||||||
self.playbook_dir = self.ansible_dir / "playbooks"
|
self.playbook_dir = self.ansible_dir / "playbooks"
|
||||||
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
|
# Ensure directories exist
|
||||||
self.ansible_dir.mkdir(exist_ok=True)
|
self.ansible_dir.mkdir(exist_ok=True)
|
||||||
(self.ansible_dir / "inventory").mkdir(exist_ok=True)
|
(self.ansible_dir / "inventory").mkdir(exist_ok=True)
|
||||||
(self.ansible_dir / "playbooks").mkdir(exist_ok=True)
|
(self.ansible_dir / "playbooks").mkdir(exist_ok=True)
|
||||||
(self.ansible_dir / "roles").mkdir(exist_ok=True)
|
(self.ansible_dir / "roles").mkdir(exist_ok=True)
|
||||||
|
self.ssh_keys_dir.mkdir(mode=0o700, exist_ok=True)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Settings helpers #
|
# Settings helpers #
|
||||||
@@ -136,12 +140,24 @@ class AnsibleService:
|
|||||||
'ansible_host': '127.0.0.1'
|
'ansible_host': '127.0.0.1'
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
hvars = {
|
settings = self.load_settings()
|
||||||
'ansible_host': device.device_ip,
|
use_password = settings.get('use_password_auth', False)
|
||||||
'ansible_user': 'pi',
|
ssh_password = settings.get('ssh_fallback_password', '')
|
||||||
'ansible_ssh_private_key_file': str(self.ssh_key_path),
|
if use_password and ssh_password:
|
||||||
'ansible_ssh_common_args': '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'
|
hvars = {
|
||||||
}
|
'ansible_host': device.device_ip,
|
||||||
|
'ansible_user': 'pi',
|
||||||
|
'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
|
children['monitoring_devices']['hosts'][device.hostname] = hvars
|
||||||
synced += 1
|
synced += 1
|
||||||
self._write_inventory(data)
|
self._write_inventory(data)
|
||||||
@@ -249,7 +265,7 @@ class AnsibleService:
|
|||||||
'name': 'Update monitoring devices',
|
'name': 'Update monitoring devices',
|
||||||
'hosts': 'all',
|
'hosts': 'all',
|
||||||
'become': True,
|
'become': True,
|
||||||
'gather_facts': True,
|
'gather_facts': False,
|
||||||
'tasks': [
|
'tasks': [
|
||||||
{
|
{
|
||||||
'name': 'Update apt cache',
|
'name': 'Update apt cache',
|
||||||
@@ -268,40 +284,24 @@ class AnsibleService:
|
|||||||
'register': 'upgrade_result'
|
'register': 'upgrade_result'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Restart device if required',
|
'name': 'Show upgrade result',
|
||||||
'reboot': {
|
'debug': {
|
||||||
'reboot_timeout': 600
|
'msg': '{{ upgrade_result.stdout_lines }}'
|
||||||
},
|
|
||||||
'when': 'upgrade_result.changed'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'Check service status',
|
|
||||||
'systemd': {
|
|
||||||
'name': 'prezenta.service',
|
|
||||||
'state': 'started'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Report update completion',
|
'name': 'Clean up apt cache',
|
||||||
'uri': {
|
'apt': {
|
||||||
'url': 'http://{{ ansible_controller_ip }}/api/update_complete',
|
'autoclean': True
|
||||||
'method': 'POST',
|
|
||||||
'body_format': 'json',
|
|
||||||
'body': {
|
|
||||||
'hostname': '{{ inventory_hostname }}',
|
|
||||||
'device_ip': '{{ ansible_host }}',
|
|
||||||
'status': 'completed',
|
|
||||||
'packages_updated': '{{ upgrade_result.stdout_lines | length }}'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
playbook_path = self.playbook_dir / "update_devices.yml"
|
playbook_path = self.playbook_dir / "update_devices.yml"
|
||||||
with open(playbook_path, 'w') as f:
|
with open(playbook_path, 'w') as f:
|
||||||
yaml.dump([playbook_content], f, default_flow_style=False)
|
yaml.dump([playbook_content], f, default_flow_style=False)
|
||||||
|
|
||||||
return str(playbook_path)
|
return str(playbook_path)
|
||||||
|
|
||||||
def create_restart_service_playbook(self) -> str:
|
def create_restart_service_playbook(self) -> str:
|
||||||
@@ -390,6 +390,17 @@ class AnsibleService:
|
|||||||
# Add extra variables
|
# Add extra variables
|
||||||
if extra_vars:
|
if extra_vars:
|
||||||
cmd.extend(['--extra-vars', json.dumps(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
|
# Create enhanced execution record using new model
|
||||||
execution_id = str(uuid.uuid4())
|
execution_id = str(uuid.uuid4())
|
||||||
@@ -416,12 +427,19 @@ class AnsibleService:
|
|||||||
with tempfile.NamedTemporaryFile(mode='w+', suffix='.log', delete=False) as log_file:
|
with tempfile.NamedTemporaryFile(mode='w+', suffix='.log', delete=False) as log_file:
|
||||||
log_file_path = log_file.name
|
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(
|
process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
cwd=str(self.ansible_dir)
|
cwd=str(self.ansible_dir),
|
||||||
|
env=env,
|
||||||
)
|
)
|
||||||
|
|
||||||
stdout, stderr = process.communicate()
|
stdout, stderr = process.communicate()
|
||||||
@@ -435,11 +453,12 @@ class AnsibleService:
|
|||||||
execution.stderr_log = stderr
|
execution.stderr_log = stderr
|
||||||
execution.ansible_log_file = log_file_path
|
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:
|
if process.returncode == 0:
|
||||||
execution.status = 'completed'
|
execution.status = 'completed'
|
||||||
execution.summary_message = 'Playbook executed successfully'
|
execution.summary_message = 'Playbook executed successfully'
|
||||||
# Parse stdout for success/failure counts
|
|
||||||
self._parse_ansible_results_enhanced(execution, stdout)
|
|
||||||
else:
|
else:
|
||||||
execution.status = 'failed'
|
execution.status = 'failed'
|
||||||
execution.summary_message = f'Playbook failed with exit code {process.returncode}'
|
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,
|
def execute_playbook_async(self, playbook_name: str, limit_hosts: List[str] = None,
|
||||||
extra_vars: Dict = None, priority: int = 5,
|
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.
|
Start a playbook in a background thread.
|
||||||
Returns immediately with the execution_id so the caller can poll /live.
|
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:
|
try:
|
||||||
self.generate_dynamic_inventory()
|
self.generate_dynamic_inventory()
|
||||||
@@ -498,6 +521,17 @@ class AnsibleService:
|
|||||||
# Pass all extra vars as a single JSON string to avoid value-quoting issues
|
# Pass all extra vars as a single JSON string to avoid value-quoting issues
|
||||||
cmd.extend(['--extra-vars', json.dumps(extra_vars)])
|
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)
|
# Create a persistent log file (NOT deleted on close)
|
||||||
log_fd, log_file_path = tempfile.mkstemp(suffix='.log', prefix='ansible_')
|
log_fd, log_file_path = tempfile.mkstemp(suffix='.log', prefix='ansible_')
|
||||||
os.close(log_fd)
|
os.close(log_fd)
|
||||||
@@ -546,6 +580,7 @@ class AnsibleService:
|
|||||||
env['PYTHONUNBUFFERED'] = '1'
|
env['PYTHONUNBUFFERED'] = '1'
|
||||||
env['ANSIBLE_FORCE_COLOR'] = '0'
|
env['ANSIBLE_FORCE_COLOR'] = '0'
|
||||||
env['ANSIBLE_NOCOLOR'] = '1'
|
env['ANSIBLE_NOCOLOR'] = '1'
|
||||||
|
env['ANSIBLE_CONFIG'] = str(self.ansible_cfg_path.resolve())
|
||||||
|
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
@@ -586,10 +621,12 @@ class AnsibleService:
|
|||||||
execution.completed_at = datetime.utcnow()
|
execution.completed_at = datetime.utcnow()
|
||||||
execution.exit_code = process.returncode
|
execution.exit_code = process.returncode
|
||||||
execution.stdout_log = full_output
|
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:
|
if process.returncode == 0:
|
||||||
execution.status = 'completed'
|
execution.status = 'completed'
|
||||||
execution.summary_message = 'Playbook executed successfully'
|
execution.summary_message = 'Playbook executed successfully'
|
||||||
self._parse_ansible_results_enhanced(execution, full_output)
|
|
||||||
else:
|
else:
|
||||||
execution.status = 'failed'
|
execution.status = 'failed'
|
||||||
execution.summary_message = f'Playbook failed (exit {process.returncode})'
|
execution.summary_message = f'Playbook failed (exit {process.returncode})'
|
||||||
@@ -653,62 +690,62 @@ class AnsibleService:
|
|||||||
return {'success': False, 'error': str(e)}
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
def _parse_ansible_results_enhanced(self, execution: PlaybookExecution, output: str):
|
def _parse_ansible_results_enhanced(self, execution: PlaybookExecution, output: str):
|
||||||
"""Parse Ansible output for enhanced result statistics"""
|
"""Parse Ansible PLAY RECAP output for result statistics."""
|
||||||
lines = output.split('\n')
|
import re
|
||||||
successful_hosts = 0
|
successful_hosts = 0
|
||||||
failed_hosts = 0
|
failed_hosts = 0
|
||||||
unreachable_hosts = 0
|
unreachable_hosts = 0
|
||||||
skipped_hosts = 0
|
skipped_hosts = 0
|
||||||
changed_hosts = 0
|
changed_hosts = 0
|
||||||
|
|
||||||
for line in lines:
|
# Match PLAY RECAP lines:
|
||||||
if 'ok=' in line and 'changed=' in line:
|
# "RPI-FOO : ok=4 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0"
|
||||||
# Parse line like: "host1: ok=4 changed=2 unreachable=0 failed=0"
|
recap_re = re.compile(
|
||||||
try:
|
r'ok=(\d+)\s+changed=(\d+)\s+unreachable=(\d+)\s+failed=(\d+)'
|
||||||
if 'failed=0' in line:
|
)
|
||||||
successful_hosts += 1
|
|
||||||
else:
|
for line in output.split('\n'):
|
||||||
failed_count = int(line.split('failed=')[1].split()[0])
|
m = recap_re.search(line)
|
||||||
if failed_count > 0:
|
if not m:
|
||||||
failed_hosts += 1
|
continue
|
||||||
else:
|
ok = int(m.group(1))
|
||||||
successful_hosts += 1
|
changed = int(m.group(2))
|
||||||
|
unreachable = int(m.group(3))
|
||||||
if 'unreachable=' in line:
|
failed = int(m.group(4))
|
||||||
unreachable = int(line.split('unreachable=')[1].split()[0])
|
|
||||||
if unreachable > 0:
|
if unreachable > 0:
|
||||||
unreachable_hosts += 1
|
unreachable_hosts += 1
|
||||||
|
elif failed > 0:
|
||||||
if 'skipped=' in line:
|
failed_hosts += 1
|
||||||
skipped = int(line.split('skipped=')[1].split()[0])
|
else:
|
||||||
if skipped > 0:
|
successful_hosts += 1
|
||||||
skipped_hosts += 1
|
|
||||||
|
if changed > 0:
|
||||||
if 'changed=' in line:
|
changed_hosts += 1
|
||||||
changed = int(line.split('changed=')[1].split()[0])
|
|
||||||
if changed > 0:
|
execution.successful_hosts = successful_hosts
|
||||||
changed_hosts += 1
|
execution.failed_hosts = failed_hosts
|
||||||
|
|
||||||
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
|
execution.unreachable_hosts = unreachable_hosts
|
||||||
execution.skipped_hosts = skipped_hosts
|
execution.skipped_hosts = skipped_hosts
|
||||||
execution.changed_hosts = changed_hosts
|
execution.changed_hosts = changed_hosts
|
||||||
|
|
||||||
def _get_playbook_description(self, playbook_name: str) -> str:
|
def _get_playbook_description(self, playbook_name: str) -> str:
|
||||||
"""Get user-friendly description for playbook"""
|
"""Get user-friendly description for playbook"""
|
||||||
descriptions = {
|
descriptions = {
|
||||||
'update_devices': 'Update all packages and monitoring software on devices',
|
'update_devices': 'Update all packages and monitoring software on devices',
|
||||||
'restart_service': 'Restart monitoring services on selected devices',
|
'restart_service': 'Restart monitoring services on selected devices',
|
||||||
'system_health': 'Check system health and monitoring status',
|
'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')
|
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:
|
def create_system_health_playbook(self) -> str:
|
||||||
"""Create system health check playbook"""
|
"""Create system health check playbook"""
|
||||||
@@ -782,6 +819,53 @@ class AnsibleService:
|
|||||||
unreachable = int(line.split('unreachable=')[1].split()[0])
|
unreachable = int(line.split('unreachable=')[1].split()[0])
|
||||||
execution.unreachable_hosts += unreachable
|
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:
|
def test_ssh_connectivity(self, device_ip: str, username: str = 'pi') -> Dict:
|
||||||
"""Test SSH connectivity to a device"""
|
"""Test SSH connectivity to a device"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Web routes for Ansible management interface
|
|||||||
"""
|
"""
|
||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||||
from app.services.ansible_service import AnsibleService
|
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
|
from config.database_config import get_db
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -89,13 +89,18 @@ def playbooks():
|
|||||||
'builtin': True
|
'builtin': True
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'restart_service',
|
'name': 'restart_service',
|
||||||
'description': 'Restart monitoring services on devices',
|
'description': 'Restart monitoring services on devices',
|
||||||
'builtin': True
|
'builtin': True
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
'name': 'distribute_ssh_keys',
|
||||||
|
'description': 'Push server public key to devices using password auth',
|
||||||
|
'builtin': True
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return render_template('ansible/playbooks.html',
|
return render_template('ansible/playbooks.html',
|
||||||
playbooks=playbooks,
|
playbooks=playbooks,
|
||||||
builtin_playbooks=builtin_playbooks)
|
builtin_playbooks=builtin_playbooks)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -123,17 +128,20 @@ def execute():
|
|||||||
})
|
})
|
||||||
seen.add(h['hostname'])
|
seen.add(h['hostname'])
|
||||||
|
|
||||||
|
settings = ansible_service.load_settings()
|
||||||
return render_template('ansible/execute.html',
|
return render_template('ansible/execute.html',
|
||||||
inventory=inventory_data,
|
inventory=inventory_data,
|
||||||
all_inv_hosts=all_inv_hosts,
|
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:
|
except Exception as e:
|
||||||
logging.error(f"Error loading execute form: {e}")
|
logging.error(f"Error loading execute form: {e}")
|
||||||
flash(f'Error loading form: {e}', 'error')
|
flash(f'Error loading form: {e}', 'error')
|
||||||
return render_template('ansible/execute.html',
|
return render_template('ansible/execute.html',
|
||||||
inventory={'groups': {}},
|
inventory={'groups': {}},
|
||||||
all_inv_hosts=[],
|
all_inv_hosts=[],
|
||||||
preselect_playbook='')
|
preselect_playbook='',
|
||||||
|
use_password_auth=False)
|
||||||
|
|
||||||
elif request.method == 'POST':
|
elif request.method == 'POST':
|
||||||
# Execute playbook
|
# Execute playbook
|
||||||
@@ -182,17 +190,24 @@ def execute():
|
|||||||
ansible_service.create_restart_service_playbook()
|
ansible_service.create_restart_service_playbook()
|
||||||
elif playbook_name == 'system_health':
|
elif playbook_name == 'system_health':
|
||||||
ansible_service.create_system_health_playbook()
|
ansible_service.create_system_health_playbook()
|
||||||
|
elif playbook_name == 'distribute_ssh_keys':
|
||||||
|
ansible_service.create_distribute_ssh_keys_playbook()
|
||||||
|
|
||||||
# Add controller IP for callbacks
|
# Add controller IP for callbacks
|
||||||
extra_vars['ansible_controller_ip'] = request.host
|
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)
|
# Use async execution (returns immediately with execution_id)
|
||||||
result = ansible_service.execute_playbook_async(
|
result = ansible_service.execute_playbook_async(
|
||||||
playbook_name=playbook_name,
|
playbook_name=playbook_name,
|
||||||
limit_hosts=selected_hosts,
|
limit_hosts=selected_hosts,
|
||||||
extra_vars=extra_vars,
|
extra_vars=extra_vars,
|
||||||
priority=priority,
|
priority=priority,
|
||||||
max_retries=max_retries
|
max_retries=max_retries,
|
||||||
|
force_password_auth=force_password,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result['success']:
|
if result['success']:
|
||||||
@@ -256,6 +271,11 @@ def execution_details(execution_id):
|
|||||||
flash(f'Error loading execution details: {e}', 'error')
|
flash(f'Error loading execution details: {e}', 'error')
|
||||||
return redirect(url_for('ansible_web.executions'))
|
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')
|
@ansible_web_bp.route('/ssh/setup')
|
||||||
def ssh_setup():
|
def ssh_setup():
|
||||||
"""SSH key setup interface"""
|
"""SSH key setup interface"""
|
||||||
@@ -288,10 +308,14 @@ def save_ssh_settings():
|
|||||||
try:
|
try:
|
||||||
fallback_password = request.form.get('ssh_fallback_password', '').strip()
|
fallback_password = request.form.get('ssh_fallback_password', '').strip()
|
||||||
if not fallback_password:
|
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'))
|
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')
|
flash('SSH settings saved successfully.', 'success')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error saving SSH settings: {e}")
|
logging.error(f"Error saving SSH settings: {e}")
|
||||||
@@ -596,4 +620,20 @@ def delete_playbook():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error deleting playbook: {e}")
|
logging.error(f"Error deleting playbook: {e}")
|
||||||
return jsonify({'error': str(e)}), 500
|
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>
|
<span class="badge bg-danger">Offline</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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>
|
<td>
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="testDevice('{{ device.name }}')">
|
<button class="btn btn-sm btn-outline-primary" onclick="testDevice('{{ device.name }}')">
|
||||||
<i class="fas fa-plug"></i>
|
<i class="fas fa-plug"></i>
|
||||||
@@ -482,7 +482,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="card-text text-muted small">
|
<p class="card-text text-muted small">
|
||||||
<i class="fas fa-clock"></i>
|
<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>
|
</p>
|
||||||
<div class="row text-center">
|
<div class="row text-center">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
|||||||
@@ -21,9 +21,11 @@
|
|||||||
{% for g in inventory.groups.values() %}{% set ns.total = ns.total + g.hosts|length %}{% endfor %}
|
{% 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 class="badge bg-primary ms-2">{{ ns.total }}</span>
|
||||||
</span>
|
</span>
|
||||||
<div class="d-flex gap-1">
|
<div class="d-flex align-items-center gap-1">
|
||||||
<button class="btn btn-sm btn-success" onclick="syncDevices()">
|
<span id="lastSyncedLabel" class="text-muted me-1" style="font-size:.72rem;"></span>
|
||||||
<i class="fas fa-sync-alt me-1"></i>Sync All
|
<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>
|
||||||
<button class="btn btn-sm btn-outline-secondary" onclick="toggleRaw()" title="Raw YAML">
|
<button class="btn btn-sm btn-outline-secondary" onclick="toggleRaw()" title="Raw YAML">
|
||||||
<i class="fas fa-code"></i>
|
<i class="fas fa-code"></i>
|
||||||
@@ -87,7 +89,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="card-body text-center py-5 text-muted">
|
<div class="card-body text-center py-5 text-muted">
|
||||||
<i class="fas fa-box-open fa-3x mb-3"></i>
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -414,22 +416,24 @@ function togglePwField(val) {
|
|||||||
document.getElementById('pwField').classList.toggle('d-none', val !== 'password');
|
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() {
|
async function syncDevices() {
|
||||||
const btn = event.currentTarget;
|
const btn = document.getElementById('syncBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Syncing…';
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Syncing…';
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${API}/inventory/sync`, {method:'POST'});
|
const r = await fetch(`${API}/inventory/sync`, {method:'POST'});
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.success) {
|
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');
|
showAlert(`<i class="fas fa-check-circle me-1"></i> ${d.message}`, 'success');
|
||||||
setTimeout(() => location.reload(), 1200);
|
setTimeout(() => location.reload(), 1200);
|
||||||
} else { showAlert(`Sync failed: ${d.error}`, 'danger'); }
|
} else { showAlert(`Sync failed: ${d.error}`, 'danger'); }
|
||||||
} catch { showAlert('Network error', 'danger'); }
|
} catch { showAlert('Network error', 'danger'); }
|
||||||
finally {
|
finally {
|
||||||
btn.disabled = false;
|
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:hover { border-color: #0d6efd; box-shadow: 0 3px 10px rgba(13,110,253,.15); }
|
||||||
.playbook-card.selected { border-color: #198754; background-color: #f8fff9; }
|
.playbook-card.selected { border-color: #198754; background-color: #f8fff9; }
|
||||||
.json-editor { font-family: 'Courier New', monospace; background-color: #f8f9fa; }
|
.json-editor { font-family: 'Courier New', monospace; background-color: #f8f9fa; }
|
||||||
#liveCard { display: none; }
|
#execPopupNotice { 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;
|
|
||||||
}
|
|
||||||
.pulse-dot {
|
.pulse-dot {
|
||||||
width: 10px; height: 10px; border-radius: 50%;
|
width: 10px; height: 10px; border-radius: 50%;
|
||||||
background: #4ec9b0; animation: pulse 1.2s infinite; display: inline-block;
|
background: #4ec9b0; animation: pulse 1.2s infinite; display: inline-block;
|
||||||
@@ -93,6 +76,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<h6 class="text-muted fw-bold mb-2 small text-uppercase">Custom Playbooks</h6>
|
||||||
<select class="form-select" id="customPlaybook">
|
<select class="form-select" id="customPlaybook">
|
||||||
@@ -240,10 +234,22 @@
|
|||||||
<option value="3">3 retries</option>
|
<option value="3">3 retries</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check">
|
<div class="form-check mb-2">
|
||||||
<input class="form-check-input" type="checkbox" id="checkMode" name="check_mode">
|
<input class="form-check-input" type="checkbox" id="checkMode" name="check_mode">
|
||||||
<label class="form-check-label" for="checkMode">Dry run (check mode)</label>
|
<label class="form-check-label" for="checkMode">Dry run (check mode)</label>
|
||||||
</div>
|
</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>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">Extra Variables (JSON)</label>
|
<label class="form-label">Extra Variables (JSON)</label>
|
||||||
@@ -300,38 +306,22 @@
|
|||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- ── Live Execution Output ──────────────────────────────────── -->
|
<!-- ── Popup launched notice ─────────────────────────────────── -->
|
||||||
<div class="row mt-3" id="liveCard">
|
<div class="row mt-3" id="execPopupNotice">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card shadow">
|
<div class="alert alert-info d-flex align-items-center gap-3 mb-0">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
<span class="pulse-dot" id="noticePulseDot"></span>
|
||||||
<span class="fw-semibold">
|
<div class="flex-grow-1">
|
||||||
<i class="fas fa-terminal me-2"></i>Live Execution Output
|
<strong>Execution started!</strong>
|
||||||
</span>
|
A live output window has been opened.
|
||||||
<div class="d-flex align-items-center gap-3">
|
If it was blocked by your browser, use the link below.
|
||||||
<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
|
|
||||||
</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>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,12 +332,7 @@
|
|||||||
// ── State ────────────────────────────────────────────────────────────
|
// ── State ────────────────────────────────────────────────────────────
|
||||||
let selectedPlaybook = null;
|
let selectedPlaybook = null;
|
||||||
let targetMode = 'all';
|
let targetMode = 'all';
|
||||||
|
let executionId = null;
|
||||||
// Live output state
|
|
||||||
let pollTimer = null;
|
|
||||||
let executionId = null;
|
|
||||||
let autoScroll = true;
|
|
||||||
let pollStartTime = null;
|
|
||||||
|
|
||||||
// ── Playbook selection ───────────────────────────────────────────────
|
// ── Playbook selection ───────────────────────────────────────────────
|
||||||
function selectPlaybook(name) {
|
function selectPlaybook(name) {
|
||||||
@@ -356,6 +341,15 @@ function selectPlaybook(name) {
|
|||||||
document.querySelector(`.playbook-card[data-name="${name}"]`)?.classList.add('selected');
|
document.querySelector(`.playbook-card[data-name="${name}"]`)?.classList.add('selected');
|
||||||
selectedPlaybook = name;
|
selectedPlaybook = name;
|
||||||
document.getElementById('selectedPlaybook').value = 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();
|
updateSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,9 +479,6 @@ document.getElementById('executeForm').addEventListener('submit', function(e) {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting…';
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting…';
|
||||||
|
|
||||||
resetLiveCard();
|
|
||||||
document.getElementById('liveCard').style.display = '';
|
|
||||||
|
|
||||||
fetch(this.action, {
|
fetch(this.action, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
@@ -495,112 +486,35 @@ document.getElementById('executeForm').addEventListener('submit', function(e) {
|
|||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-play me-1"></i>Execute';
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
executionId = data.execution_id;
|
executionId = data.execution_id;
|
||||||
pollStartTime = Date.now();
|
const popupUrl = `/ansible/executions/${executionId}/live-popup`;
|
||||||
const link = document.getElementById('liveDetailsLink');
|
const detailUrl = `/ansible/executions/${executionId}`;
|
||||||
link.href = `/ansible/executions/${executionId}`;
|
|
||||||
link.style.display = '';
|
// Open independent popup window
|
||||||
startPolling();
|
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 {
|
} else {
|
||||||
setLiveError(data.error || 'Unknown error');
|
alert('Execution failed: ' + (data.error || 'Unknown error'));
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = '<i class="fas fa-play me-1"></i>Execute';
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
setLiveError('Network error: ' + err);
|
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="fas fa-play me-1"></i>Execute';
|
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 ───────────────────────────────────────────────────────
|
// ── Initialize ───────────────────────────────────────────────────────
|
||||||
updateTargetCount();
|
updateTargetCount();
|
||||||
|
|||||||
@@ -141,7 +141,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<small class="text-muted">Started:</small><br>
|
<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>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<small class="text-muted">Duration:</small><br>
|
<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>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="text-muted small">
|
<p class="text-muted small">
|
||||||
When key-based authentication fails, the server falls back to password auth.
|
Configure SSH authentication for Ansible. Enable password mode to authenticate
|
||||||
Set the default password for devices on this network below.
|
with a username/password instead of SSH keys.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('ansible_web.save_ssh_settings') }}">
|
<form method="post" action="{{ url_for('ansible_web.save_ssh_settings') }}">
|
||||||
<div class="mb-3">
|
<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">
|
<div class="input-group">
|
||||||
<input type="password" name="ssh_fallback_password" id="sshFallbackPassword"
|
<input type="password" name="ssh_fallback_password" id="sshFallbackPassword"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
value="{{ settings.get('ssh_fallback_password', '') }}"
|
value="{{ settings.get('ssh_fallback_password', '') }}"
|
||||||
placeholder="Enter fallback password"
|
placeholder="Enter device password"
|
||||||
required>
|
required>
|
||||||
<button class="btn btn-outline-secondary" type="button"
|
<button class="btn btn-outline-secondary" type="button"
|
||||||
onclick="togglePassword()">
|
onclick="togglePassword()">
|
||||||
@@ -86,6 +86,19 @@
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</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">
|
<button type="submit" class="btn btn-success">
|
||||||
<i class="fas fa-save me-1"></i>Save Settings
|
<i class="fas fa-save me-1"></i>Save Settings
|
||||||
</button>
|
</button>
|
||||||
@@ -95,6 +108,87 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /row -->
|
</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 -->
|
</div><!-- /container -->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -109,5 +203,95 @@ function togglePassword() {
|
|||||||
icon.classList.replace('fa-eye-slash', 'fa-eye');
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -303,6 +303,18 @@
|
|||||||
Execute
|
Execute
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
<div class="nav-section">Server</div>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
|
|||||||
@@ -166,7 +166,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<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>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-{% if log.severity == 'error' %}danger{% elif log.severity == 'warning' %}warning{% elif log.severity == 'info' %}primary{% else %}secondary{% endif %}">
|
<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 %}
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="info-label">Last Seen</td>
|
<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>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,13 +148,13 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="info-label">Config Updated</td>
|
<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>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="info-label">Info Reviewed</td>
|
<td class="info-label">Info Reviewed</td>
|
||||||
<td>
|
<td>
|
||||||
{% if device.info_reviewed_at and device.info_reviewed_at.year > 1970 %}
|
{% 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 %}
|
{% else %}
|
||||||
<span class="text-muted">Never reviewed</span>
|
<span class="text-muted">Never reviewed</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -207,7 +207,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for log in logs %}
|
{% for log in logs %}
|
||||||
<tr class="log-row">
|
<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>
|
<td>
|
||||||
<span class="severity-{{ log.severity }}">
|
<span class="severity-{{ log.severity }}">
|
||||||
<i class="fas fa-circle fa-xs me-1"></i>{{ log.severity }}
|
<i class="fas fa-circle fa-xs me-1"></i>{{ log.severity }}
|
||||||
|
|||||||
@@ -77,13 +77,13 @@
|
|||||||
{% if device.mac_address %}
|
{% if device.mac_address %}
|
||||||
<div class="alert alert-light border small mb-4">
|
<div class="alert alert-light border small mb-4">
|
||||||
<strong>Config last updated:</strong>
|
<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>
|
<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 %}">
|
<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>
|
</span><br>
|
||||||
<strong>Last seen:</strong>
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -124,14 +124,14 @@
|
|||||||
<td>{{ device_log_counts.get(device.id, 0) }}</td>
|
<td>{{ device_log_counts.get(device.id, 0) }}</td>
|
||||||
<td class="text-muted">
|
<td class="text-muted">
|
||||||
{% if device.last_seen %}
|
{% if device.last_seen %}
|
||||||
{{ device.last_seen.strftime('%Y-%m-%d %H:%M') }}
|
{{ device.last_seen | local_dt }}
|
||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if device.mac_address and device.config_updated_at %}
|
{% 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>
|
<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>
|
</span>
|
||||||
{% elif device.mac_address %}
|
{% elif device.mac_address %}
|
||||||
<span class="sync-old"><i class="fas fa-clock"></i> Never</span>
|
<span class="sync-old"><i class="fas fa-clock"></i> Never</span>
|
||||||
|
|||||||
@@ -163,7 +163,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="log-meta mt-2">
|
<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 %}
|
{% if log.template_hash %}
|
||||||
<span class="ms-3"><i class="fas fa-tag"></i> Template: {{ log.template_hash[:8] }}</span>
|
<span class="ms-3"><i class="fas fa-tag"></i> Template: {{ log.template_hash[:8] }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
<td>{{ template.template_text[:80] }}{% if template.template_text | length > 80 %}...{% endif %}</td>
|
<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><span class="badge bg-secondary">{{ template.category or 'uncategorized' }}</span></td>
|
||||||
<td>{{ template.usage_count }}</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>
|
<td>
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="viewTemplate('{{ template.template_hash }}')">
|
<button class="btn btn-sm btn-outline-primary" onclick="viewTemplate('{{ template.template_hash }}')">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
|
|||||||
@@ -65,12 +65,12 @@
|
|||||||
{% if device %}
|
{% if device %}
|
||||||
<div class="alert alert-light border small mb-4">
|
<div class="alert alert-light border small mb-4">
|
||||||
<strong>Last seen:</strong>
|
<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>
|
<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>
|
<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 %}">
|
<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>
|
</span>
|
||||||
<br><small class="text-muted">Updated automatically when you save this form, accept or reject a device request.</small>
|
<br><small class="text-muted">Updated automatically when you save this form, accept or reject a device request.</small>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,10 +36,10 @@
|
|||||||
<td>{{ d.hostname or '—' }}</td>
|
<td>{{ d.hostname or '—' }}</td>
|
||||||
<td>{{ d.device_ip or '—' }}</td>
|
<td>{{ d.device_ip or '—' }}</td>
|
||||||
<td class="text-muted small">
|
<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>
|
||||||
<td class="text-muted small">
|
<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>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<a href="{{ url_for('wmt_web.device_edit', device_id=d.id) }}"
|
<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>
|
<p class="text-muted mb-1 small">Config Last Updated</p>
|
||||||
<h6 class="mb-0">
|
<h6 class="mb-0">
|
||||||
{% if global_cfg and global_cfg.updated_at %}
|
{% 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 %}
|
{% else %}
|
||||||
Never
|
Never
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
<td><code>{{ d.mac_address }}</code></td>
|
<td><code>{{ d.mac_address }}</code></td>
|
||||||
<td>{{ d.device_ip or '—' }}</td>
|
<td>{{ d.device_ip or '—' }}</td>
|
||||||
<td class="text-muted small">
|
<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>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('main.device_edit', device_id=d.id) }}"
|
<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">
|
<li class="list-group-item d-flex justify-content-between align-items-start py-2">
|
||||||
<div>
|
<div>
|
||||||
<code class="small">{{ r.mac_address }}</code><br>
|
<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>
|
</div>
|
||||||
<span class="badge badge-{{ r.status }} rounded-pill">{{ r.status }}</span>
|
<span class="badge badge-{{ r.status }} rounded-pill">{{ r.status }}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -69,12 +69,12 @@
|
|||||||
<td>{{ r.proposed_device_name or '—' }}</td>
|
<td>{{ r.proposed_device_name or '—' }}</td>
|
||||||
<td>{{ r.proposed_hostname or '—' }}</td>
|
<td>{{ r.proposed_hostname or '—' }}</td>
|
||||||
<td>{{ r.proposed_device_ip 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 class="text-muted small">{{ r.client_config_mtime or '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge badge-{{ r.status }} rounded-pill">{{ r.status }}</span>
|
<span class="badge badge-{{ r.status }} rounded-pill">{{ r.status }}</span>
|
||||||
{% if r.admin_reviewed_at %}
|
{% 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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% if status_filter == 'pending' or status_filter == 'all' %}
|
{% if status_filter == 'pending' or status_filter == 'all' %}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<small class="text-muted ms-3">
|
<small class="text-muted ms-3">
|
||||||
Applied to all WMT devices on next sync.
|
Applied to all WMT devices on next sync.
|
||||||
{% if cfg and cfg.updated_at %}
|
{% 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 %}
|
{% endif %}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user