Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc2be88915 | |||
| cd0b725f3e | |||
| 77c74098a3 | |||
| aa52047016 | |||
| e166e3fcb4 | |||
| d4d4208145 | |||
| 61408645a5 | |||
| 25baec67ac | |||
| c71d0da97d | |||
| 38297e616f | |||
| ca41486c45 |
@@ -59,6 +59,8 @@ def inject_config():
|
||||
# In-memory diagnostic job store { job_id: { status, result, created_at } }
|
||||
_diag_jobs: dict = {}
|
||||
_diag_lock = threading.Lock()
|
||||
# Per-user rate-limit: { username: [epoch_float, ...] } — cleaned inside _diag_lock
|
||||
_diag_rate: dict = {}
|
||||
|
||||
|
||||
def _purge_old_jobs_loop():
|
||||
@@ -92,6 +94,14 @@ def _config() -> dict:
|
||||
return _cfg
|
||||
|
||||
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
|
||||
response.headers.setdefault('X-Frame-Options', 'DENY')
|
||||
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||
return response
|
||||
|
||||
|
||||
def _daemon_ok(last_check: str) -> bool:
|
||||
"""Return True if monitor last checked within 20 minutes."""
|
||||
if not last_check or last_check == 'Never':
|
||||
@@ -180,7 +190,11 @@ def index():
|
||||
summary = db.get_status_summary()
|
||||
snapshot_raw = db.get_state('network_snapshot')
|
||||
last_check = db.get_state('last_check', 'Never')
|
||||
snapshot = json.loads(snapshot_raw) if snapshot_raw else {}
|
||||
try:
|
||||
snapshot = json.loads(snapshot_raw) if snapshot_raw else {}
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to parse network_snapshot JSON: {e}')
|
||||
snapshot = {}
|
||||
suppressions = db.get_active_suppressions()
|
||||
_annotate_suppressions(events, suppressions)
|
||||
recent_resolved = db.get_recent_resolved(hours=24, limit=10)
|
||||
@@ -219,7 +233,11 @@ def suppressions_page():
|
||||
active = db.get_active_suppressions()
|
||||
history = db.get_suppression_history(limit=50)
|
||||
snapshot_raw = db.get_state('network_snapshot')
|
||||
snapshot = json.loads(snapshot_raw) if snapshot_raw else {}
|
||||
try:
|
||||
snapshot = json.loads(snapshot_raw) if snapshot_raw else {}
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to parse network_snapshot JSON: {e}')
|
||||
snapshot = {}
|
||||
return render_template(
|
||||
'suppressions.html',
|
||||
user=user,
|
||||
@@ -266,10 +284,13 @@ def api_network():
|
||||
def api_links():
|
||||
raw = db.get_state('link_stats')
|
||||
if raw:
|
||||
if len(raw) > 10_000_000:
|
||||
logger.error(f'link_stats exceeds 10 MB ({len(raw)} bytes); possible corruption')
|
||||
return jsonify({'error': 'Invalid cached data'}), 503
|
||||
try:
|
||||
return jsonify(json.loads(raw))
|
||||
except Exception:
|
||||
logger.error('Failed to parse link_stats JSON')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to parse link_stats JSON: {e}')
|
||||
return jsonify({'hosts': {}, 'updated': None})
|
||||
|
||||
|
||||
@@ -325,13 +346,21 @@ def api_create_suppression():
|
||||
if len(target_detail) > 255:
|
||||
return jsonify({'error': 'target_detail must be 255 characters or fewer'}), 400
|
||||
|
||||
if expires_minutes is not None:
|
||||
try:
|
||||
expires_minutes = int(expires_minutes)
|
||||
if expires_minutes <= 0 or expires_minutes > 43200:
|
||||
return jsonify({'error': 'expires_minutes must be between 1 and 43200 (30 days)'}), 400
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': 'expires_minutes must be a valid integer'}), 400
|
||||
|
||||
sup_id = db.create_suppression(
|
||||
target_type=target_type,
|
||||
target_name=target_name,
|
||||
target_detail=target_detail,
|
||||
reason=reason,
|
||||
suppressed_by=user['username'],
|
||||
expires_minutes=int(expires_minutes) if expires_minutes else None,
|
||||
expires_minutes=expires_minutes,
|
||||
)
|
||||
logger.info(
|
||||
f'Suppression #{sup_id} created by {user["username"]}: '
|
||||
@@ -369,8 +398,8 @@ def api_diagnose_start():
|
||||
return jsonify({'error': 'No link_stats data available'}), 503
|
||||
try:
|
||||
link_data = json.loads(raw)
|
||||
except Exception:
|
||||
logger.error('Failed to parse link_stats JSON in /api/diagnose')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to parse link_stats JSON in /api/diagnose: {e}')
|
||||
return jsonify({'error': 'Internal data error'}), 500
|
||||
|
||||
switches = link_data.get('unifi_switches', {})
|
||||
@@ -394,6 +423,9 @@ def api_diagnose_start():
|
||||
return jsonify({'error': 'No LLDP neighbor data for this port'}), 400
|
||||
|
||||
server_name = lldp['system_name']
|
||||
if not re.fullmatch(r'[a-zA-Z0-9._-]+', server_name):
|
||||
logger.error(f'Refusing diagnostic: invalid server_name from LLDP: {server_name!r}')
|
||||
return jsonify({'error': 'LLDP neighbor name contains invalid characters'}), 400
|
||||
lldp_port_id = lldp.get('port_id', '')
|
||||
|
||||
# Find matching host + interface in link_stats hosts
|
||||
@@ -419,9 +451,14 @@ def api_diagnose_start():
|
||||
# Resolve host IP from link_stats host data
|
||||
host_ip = (server_ifaces.get(matched_iface) or {}).get('host_ip')
|
||||
if not host_ip:
|
||||
# Fallback: use LLDP mgmt IPs
|
||||
mgmt_ips = lldp.get('mgmt_ips') or []
|
||||
host_ip = mgmt_ips[0] if mgmt_ips else None
|
||||
# Fallback: use first valid IP from LLDP mgmt IPs
|
||||
for candidate in (lldp.get('mgmt_ips') or []):
|
||||
try:
|
||||
ipaddress.ip_address(candidate)
|
||||
host_ip = candidate
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if not host_ip:
|
||||
return jsonify({'error': 'Cannot determine host IP for SSH'}), 400
|
||||
|
||||
@@ -436,8 +473,22 @@ def api_diagnose_start():
|
||||
return jsonify({'error': 'Resolved interface name contains invalid characters'}), 400
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
requesting_user = _get_user()['username']
|
||||
now = time.time()
|
||||
with _diag_lock:
|
||||
_diag_jobs[job_id] = {'status': 'running', 'result': None, 'created_at': time.time()}
|
||||
# Rate limit: max 5 diagnostic jobs per user per minute; prune stale user entries
|
||||
stale_users = [u for u, ts in _diag_rate.items() if not ts or max(ts) < now - 3600]
|
||||
for u in stale_users:
|
||||
del _diag_rate[u]
|
||||
recent = [t for t in _diag_rate.get(requesting_user, []) if now - t < 60]
|
||||
if len(recent) >= 5:
|
||||
return jsonify({'error': 'Rate limit exceeded: max 5 diagnostics per minute'}), 429
|
||||
recent.append(now)
|
||||
_diag_rate[requesting_user] = recent
|
||||
_diag_jobs[job_id] = {
|
||||
'status': 'running', 'result': None,
|
||||
'created_at': now, 'user': requesting_user,
|
||||
}
|
||||
|
||||
def _run():
|
||||
try:
|
||||
@@ -447,7 +498,7 @@ def api_diagnose_start():
|
||||
result = runner.run(host_ip, server_name, matched_iface, port_data)
|
||||
except Exception as e:
|
||||
logger.error(f'Diagnostic job {job_id} failed: {e}', exc_info=True)
|
||||
result = {'status': 'error', 'error': str(e)}
|
||||
result = {'status': 'error', 'error': 'Diagnostic failed; check server logs.'}
|
||||
with _diag_lock:
|
||||
if job_id in _diag_jobs:
|
||||
_diag_jobs[job_id]['status'] = 'done'
|
||||
@@ -463,11 +514,15 @@ def api_diagnose_start():
|
||||
@require_auth
|
||||
def api_diagnose_poll(job_id: str):
|
||||
"""Poll a diagnostic job. Returns {status, result}."""
|
||||
current_user = _get_user()['username']
|
||||
with _diag_lock:
|
||||
job = _diag_jobs.get(job_id)
|
||||
if not job:
|
||||
return jsonify({'error': 'Job not found'}), 404
|
||||
return jsonify({'status': job['status'], 'result': job.get('result')})
|
||||
if not job:
|
||||
return jsonify({'error': 'Job not found'}), 404
|
||||
if job.get('user') != current_user:
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
snapshot = {'status': job['status'], 'result': job.get('result')}
|
||||
return jsonify(snapshot)
|
||||
|
||||
|
||||
@app.route('/api/avatar')
|
||||
@@ -484,11 +539,21 @@ def api_avatar():
|
||||
|
||||
# Build a safe cache filename from the username (alphanumeric + - _ .)
|
||||
safe_name = re.sub(r'[^a-zA-Z0-9._-]', '_', username)
|
||||
cache_dir = ldap_cfg.get('cache_dir', os.path.join(tempfile.gettempdir(), 'gandalf_avatars'))
|
||||
cache_dir = os.path.abspath(
|
||||
ldap_cfg.get('cache_dir', os.path.join(tempfile.gettempdir(), 'gandalf_avatars'))
|
||||
)
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
cache_file = os.path.join(cache_dir, f'user_{safe_name}.jpg')
|
||||
sentinel = os.path.join(cache_dir, f'user_{safe_name}.none')
|
||||
cache_ttl = int(ldap_cfg.get('cache_ttl', 3600))
|
||||
cache_file = os.path.abspath(os.path.join(cache_dir, f'user_{safe_name}.jpg'))
|
||||
sentinel = os.path.abspath(os.path.join(cache_dir, f'user_{safe_name}.none'))
|
||||
# Guard against path escape (shouldn't happen with sanitised safe_name, but be explicit)
|
||||
if not cache_file.startswith(cache_dir + os.sep) or not sentinel.startswith(cache_dir + os.sep):
|
||||
logger.error(f'Avatar path escape detected for user {username!r}')
|
||||
return '', 404
|
||||
try:
|
||||
cache_ttl = int(ldap_cfg.get('cache_ttl', 3600))
|
||||
except (ValueError, TypeError):
|
||||
logger.warning('Invalid cache_ttl in ldap config; using default 3600')
|
||||
cache_ttl = 3600
|
||||
|
||||
now = time.time()
|
||||
|
||||
@@ -498,33 +563,48 @@ def api_avatar():
|
||||
max_age=cache_ttl, conditional=True)
|
||||
|
||||
# Skip LDAP if we already know this user has no avatar
|
||||
if os.path.exists(sentinel) and now - os.path.getmtime(sentinel) < cache_ttl:
|
||||
return '', 404
|
||||
try:
|
||||
if os.path.exists(sentinel) and now - os.path.getmtime(sentinel) < cache_ttl:
|
||||
return '', 404
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Query lldap
|
||||
bind_pw = ldap_cfg.get('bind_pw', '')
|
||||
if not bind_pw:
|
||||
logger.error('LDAP bind_pw not configured — avatar lookup disabled')
|
||||
return '', 404
|
||||
|
||||
avatar_data = None
|
||||
conn = None
|
||||
try:
|
||||
import ldap3
|
||||
server = ldap3.Server(ldap_cfg['host'], port=int(ldap_cfg.get('port', 3890)))
|
||||
conn = ldap3.Connection(server,
|
||||
user=ldap_cfg['bind_dn'],
|
||||
password=ldap_cfg.get('bind_pw', ''),
|
||||
password=bind_pw,
|
||||
auto_bind=True, receive_timeout=5)
|
||||
safe_uid = ldap3.utils.conv.escape_filter_chars(username)
|
||||
conn.search(ldap_cfg.get('user_base', 'ou=people,dc=example,dc=com'),
|
||||
f'(uid={safe_uid})', attributes=['avatar'])
|
||||
if conn.entries and conn.entries[0]['avatar'].value:
|
||||
avatar_data = conn.entries[0]['avatar'].value
|
||||
conn.unbind()
|
||||
except ImportError:
|
||||
logger.error('ldap3 not installed — run: pip install ldap3')
|
||||
return '', 404
|
||||
except Exception as e:
|
||||
logger.error(f'LDAP avatar lookup failed for {username}: {e}')
|
||||
return '', 404
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.unbind()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not avatar_data or len(avatar_data) < 100:
|
||||
open(sentinel, 'w').close()
|
||||
with open(sentinel, 'w'):
|
||||
pass
|
||||
return '', 404
|
||||
|
||||
# Validate JPEG magic bytes (FF D8 FF)
|
||||
@@ -557,7 +637,8 @@ def health():
|
||||
db.get_state('last_check')
|
||||
checks['db'] = 'ok'
|
||||
except Exception as e:
|
||||
checks['db'] = f'error: {e}'
|
||||
logger.error(f'Health check db error: {e}')
|
||||
checks['db'] = 'error'
|
||||
overall = 'degraded'
|
||||
|
||||
# Monitor freshness: fail if last_check is older than 20 minutes
|
||||
@@ -567,14 +648,15 @@ def health():
|
||||
ts = datetime.strptime(last_check, '%Y-%m-%d %H:%M:%S UTC').replace(tzinfo=timezone.utc)
|
||||
age_s = (datetime.now(timezone.utc) - ts).total_seconds()
|
||||
if age_s > 1200:
|
||||
checks['monitor'] = f'stale ({int(age_s)}s since last check)'
|
||||
checks['monitor'] = 'stale'
|
||||
overall = 'degraded'
|
||||
else:
|
||||
checks['monitor'] = f'ok ({int(age_s)}s ago)'
|
||||
checks['monitor'] = 'ok'
|
||||
else:
|
||||
checks['monitor'] = 'no data yet'
|
||||
except Exception as e:
|
||||
checks['monitor'] = f'error: {e}'
|
||||
logger.error(f'Health check monitor error: {e}')
|
||||
checks['monitor'] = 'error'
|
||||
overall = 'degraded'
|
||||
|
||||
status_code = 200 if overall == 'ok' else 503
|
||||
|
||||
@@ -365,7 +365,7 @@ def is_suppressed(target_type: str, target_name: str, target_detail: str = '') -
|
||||
"""SELECT id FROM suppression_rules
|
||||
WHERE active=TRUE AND (expires_at IS NULL OR expires_at > NOW())
|
||||
AND target_type=%s AND target_name=%s
|
||||
AND (target_detail IS NULL OR target_detail='') LIMIT 1""",
|
||||
AND target_detail='' LIMIT 1""",
|
||||
(target_type, target_name),
|
||||
)
|
||||
if cur.fetchone():
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ class DiagnosticsRunner:
|
||||
)
|
||||
|
||||
return (
|
||||
f'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 '
|
||||
f'ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 '
|
||||
f'-o BatchMode=yes -o LogLevel=ERROR '
|
||||
f'-o ServerAliveInterval=10 -o ServerAliveCountMax=2 '
|
||||
f'root@{ip_q} \'{remote_cmd}\''
|
||||
|
||||
+22
-22
@@ -11,7 +11,6 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
@@ -21,7 +20,6 @@ from urllib3.exceptions import InsecureRequestWarning
|
||||
|
||||
import db
|
||||
|
||||
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -91,7 +89,9 @@ class UnifiClient:
|
||||
self.base_url = cfg['controller']
|
||||
self.site_id = cfg.get('site_id', 'default')
|
||||
self.session = requests.Session()
|
||||
self.session.verify = False
|
||||
self.session.verify = cfg.get('verify_ssl', True)
|
||||
if not self.session.verify:
|
||||
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
|
||||
self.headers = {
|
||||
'X-API-KEY': cfg['api_key'],
|
||||
'Accept': 'application/json',
|
||||
@@ -263,7 +263,10 @@ class PulseClient:
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
execution_id = resp.json()['execution_id']
|
||||
execution_id = resp.json().get('execution_id')
|
||||
if not execution_id:
|
||||
logger.error('Pulse submit response missing execution_id')
|
||||
return None
|
||||
self.last_execution_id = execution_id
|
||||
except Exception as e:
|
||||
logger.error(f'Pulse command submit failed: {e}')
|
||||
@@ -315,6 +318,14 @@ class PulseClient:
|
||||
return self.run_command(command, _retry=False)
|
||||
return None
|
||||
|
||||
def ping(self, ip: str, count: int = 3, timeout: int = 2) -> bool:
|
||||
"""Ping *ip* via the Pulse worker. Returns True if host responds."""
|
||||
ip_q = shlex.quote(ip)
|
||||
output = self.run_command(
|
||||
f'ping -c {count} -W {timeout} {ip_q} >/dev/null 2>&1 && echo REACHABLE || echo UNREACHABLE'
|
||||
)
|
||||
return output is not None and output.strip() == 'REACHABLE'
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Link stats collector (ethtool + Prometheus traffic metrics)
|
||||
@@ -344,8 +355,8 @@ class LinkStatsCollector:
|
||||
if not ifaces or not self.pulse.url:
|
||||
return {}
|
||||
|
||||
# Validate interface names (kernel names only contain [a-zA-Z0-9_.-])
|
||||
safe_ifaces = [i for i in ifaces if re.match(r'^[a-zA-Z0-9_.@-]+$', i)]
|
||||
# Validate interface names (kernel names: [a-zA-Z0-9_.-], max 15 chars per IFNAMSIZ)
|
||||
safe_ifaces = [i for i in ifaces if re.match(r'^[a-zA-Z0-9_.-]{1,15}$', i)]
|
||||
if not safe_ifaces:
|
||||
return {}
|
||||
|
||||
@@ -363,7 +374,7 @@ class LinkStatsCollector:
|
||||
shell_cmd = ' '.join(parts)
|
||||
|
||||
ssh_cmd = (
|
||||
f'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 '
|
||||
f'ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 '
|
||||
f'-o BatchMode=yes -o LogLevel=ERROR '
|
||||
f'-o ServerAliveInterval=10 -o ServerAliveCountMax=2 '
|
||||
f'root@{ip} "{shell_cmd}"'
|
||||
@@ -638,19 +649,6 @@ class LinkStatsCollector:
|
||||
# --------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------
|
||||
def ping(ip: str, count: int = 3, timeout: int = 2) -> bool:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['ping', '-c', str(count), '-W', str(timeout), ip],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=30,
|
||||
)
|
||||
return r.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _now_utc() -> str:
|
||||
return datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
|
||||
|
||||
@@ -671,6 +669,7 @@ class NetworkMonitor:
|
||||
self.unifi = UnifiClient(self.cfg['unifi'])
|
||||
self.tickets = TicketClient(self.cfg.get('ticket_api', {}))
|
||||
self.link_stats = LinkStatsCollector(self.cfg, self.prom, self.unifi)
|
||||
self.pulse = self.link_stats.pulse # convenience alias
|
||||
|
||||
mon = self.cfg.get('monitor', {})
|
||||
self.poll_interval = mon.get('poll_interval', 120)
|
||||
@@ -838,7 +837,7 @@ class NetworkMonitor:
|
||||
def _process_ping_hosts(self, suppressions: list) -> None:
|
||||
for h in self.cfg.get('monitor', {}).get('ping_hosts', []):
|
||||
name, ip = h['name'], h['ip']
|
||||
reachable = ping(ip)
|
||||
reachable = self.pulse.ping(ip)
|
||||
|
||||
if not reachable:
|
||||
sup = db.check_suppressed(suppressions, 'host', name)
|
||||
@@ -908,7 +907,7 @@ class NetworkMonitor:
|
||||
|
||||
for h in self.cfg.get('monitor', {}).get('ping_hosts', []):
|
||||
name, ip = h['name'], h['ip']
|
||||
reachable = ping(ip, count=1, timeout=2)
|
||||
reachable = self.pulse.ping(ip, count=1, timeout=2)
|
||||
hosts[name] = {
|
||||
'ip': ip,
|
||||
'interfaces': {},
|
||||
@@ -967,6 +966,7 @@ class NetworkMonitor:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Monitor loop error: {e}', exc_info=True)
|
||||
time.sleep(30)
|
||||
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
|
||||
+1
-1
@@ -313,7 +313,7 @@
|
||||
|
||||
<script>
|
||||
const GANDALF_CONFIG = {
|
||||
ticket_web_url: "{{ config.get('ticket_api', {}).get('web_url', 'http://t.lotusguild.org/ticket/') }}"
|
||||
ticket_web_url: {{ config.get('ticket_api', {}).get('web_url', 'http://t.lotusguild.org/ticket/') | tojson }}
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
<!-- ── Status bar ──────────────────────────────────────────────────── -->
|
||||
<div class="status-bar">
|
||||
<div class="status-chips">
|
||||
<div class="status-chips" id="status-chips" aria-live="polite" aria-atomic="true">
|
||||
{% if not daemon_ok %}
|
||||
<span class="chip chip-critical">⚠ MONITOR OFFLINE</span>
|
||||
{% endif %}
|
||||
@@ -30,7 +30,8 @@
|
||||
<div class="lt-stats-grid">
|
||||
<div class="lt-stat-card{% if summary.critical %} lt-stat-card--alert{% endif %}"
|
||||
id="stat-critical" role="button" tabindex="0"
|
||||
data-stat-filter="critical" aria-label="{{ summary.critical or 0 }} critical alerts">
|
||||
data-stat-filter="critical" aria-label="{{ summary.critical or 0 }} critical alerts"
|
||||
aria-controls="events-table-wrap">
|
||||
<span class="lt-stat-icon lt-text-red" aria-hidden="true">●</span>
|
||||
<div class="lt-stat-info">
|
||||
<span class="lt-stat-value lt-text-red" id="stat-critical-val">{{ summary.critical or 0 }}</span>
|
||||
@@ -39,7 +40,8 @@
|
||||
</div>
|
||||
<div class="lt-stat-card"
|
||||
id="stat-warning" role="button" tabindex="0"
|
||||
data-stat-filter="warning" aria-label="{{ summary.warning or 0 }} warning alerts">
|
||||
data-stat-filter="warning" aria-label="{{ summary.warning or 0 }} warning alerts"
|
||||
aria-controls="events-table-wrap">
|
||||
<span class="lt-stat-icon lt-text-amber" aria-hidden="true">●</span>
|
||||
<div class="lt-stat-info">
|
||||
<span class="lt-stat-value lt-text-amber" id="stat-warning-val">{{ summary.warning or 0 }}</span>
|
||||
@@ -484,6 +486,7 @@
|
||||
|
||||
function setCollapsed(v) {
|
||||
wrap.classList.toggle('is-collapsed', v);
|
||||
wrap.setAttribute('aria-hidden', v ? 'true' : 'false');
|
||||
btn.setAttribute('aria-expanded', v ? 'false' : 'true');
|
||||
btn.textContent = v ? '▾ Expand' : '▴ Collapse';
|
||||
try { localStorage.setItem(LS_KEY, v ? '1' : '0'); } catch(_) {}
|
||||
|
||||
@@ -107,10 +107,8 @@ function portBlockHtml(idx, port, swName, sfpBlock) {
|
||||
const sfpCls = sfpBlock ? ' sfp-block' : '';
|
||||
const speedTxt = portSpeedLabel(port);
|
||||
// LLDP neighbor: first 6 chars of hostname
|
||||
const lldpName = (port && port.lldp_table && port.lldp_table.length)
|
||||
? escHtml((port.lldp_table[0].chassis_id_subtype === 'local'
|
||||
? port.lldp_table[0].chassis_id
|
||||
: port.lldp_table[0].system_name || port.lldp_table[0].chassis_id || '').slice(0, 6))
|
||||
const lldpName = (port && port.lldp && (port.lldp.system_name || port.lldp.chassis_id))
|
||||
? escHtml((port.lldp.system_name || port.lldp.chassis_id || '').slice(0, 6))
|
||||
: '';
|
||||
const lldpHtml = lldpName ? `<span class="port-lldp">${lldpName}</span>` : '';
|
||||
const speedHtml = speedTxt ? `<span class="port-speed">${speedTxt}</span>` : '';
|
||||
@@ -162,10 +160,8 @@ function renderChassis(swName, sw) {
|
||||
const state = portBlockState(port);
|
||||
const title = port ? escHtml(port.name) : `Port ${idx}`;
|
||||
const speedTxt = portSpeedLabel(port);
|
||||
const lldpName = (port && port.lldp_table && port.lldp_table.length)
|
||||
? escHtml((port.lldp_table[0].chassis_id_subtype === 'local'
|
||||
? port.lldp_table[0].chassis_id
|
||||
: port.lldp_table[0].system_name || port.lldp_table[0].chassis_id || '').slice(0, 6))
|
||||
const lldpName = (port && port.lldp && (port.lldp.system_name || port.lldp.chassis_id))
|
||||
? escHtml((port.lldp.system_name || port.lldp.chassis_id || '').slice(0, 6))
|
||||
: '';
|
||||
const speedHtml = speedTxt ? `<span class="port-speed">${speedTxt}</span>` : '';
|
||||
const lldpHtml = lldpName ? `<span class="port-lldp">${lldpName}</span>` : '';
|
||||
@@ -231,6 +227,7 @@ function selectPort(el) {
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
if (_diagPollTimer) { clearInterval(_diagPollTimer); _diagPollTimer = null; }
|
||||
document.getElementById('inspector-panel').classList.remove('open');
|
||||
document.querySelectorAll('.switch-port-block.selected')
|
||||
.forEach(el => el.classList.remove('selected'));
|
||||
@@ -262,7 +259,7 @@ function renderPanel(swName, idx) {
|
||||
const poeCurStr = (d.poe_power != null && d.poe_power > 0) ? ` / draw <span class="val-amber">${d.poe_power.toFixed(1)}W</span>` : '';
|
||||
poeHtml = `
|
||||
<div class="lt-divider"><span class="lt-divider-label">PoE</span></div>
|
||||
<div class="panel-row"><span class="panel-label">Class</span><span class="panel-val">class ${d.poe_class}${poeMaxStr}</span></div>
|
||||
<div class="panel-row"><span class="panel-label">Class</span><span class="panel-val">class ${escHtml(String(d.poe_class))}${poeMaxStr}</span></div>
|
||||
${d.poe_power != null ? `<div class="panel-row"><span class="panel-label">Draw</span><span class="panel-val">${d.poe_power > 0 ? `<span class="val-amber">${d.poe_power.toFixed(1)}W</span>` : '0W'}</span></div>` : ''}
|
||||
${d.poe_mode ? `<div class="panel-row"><span class="panel-label">Mode</span><span class="panel-val">${escHtml(d.poe_mode)}</span></div>` : ''}`;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ from diagnose import DiagnosticsRunner # noqa: E402
|
||||
# ── build_ssh_command ────────────────────────────────────────────────────────
|
||||
|
||||
class TestBuildSshCommand:
|
||||
def test_contains_stricthostkeychecking_no(self):
|
||||
def test_contains_stricthostkeychecking_accept_new(self):
|
||||
cmd = DiagnosticsRunner.build_ssh_command('10.0.0.1', 'eth0')
|
||||
assert 'StrictHostKeyChecking=no' in cmd
|
||||
assert 'StrictHostKeyChecking=accept-new' in cmd
|
||||
|
||||
def test_contains_host_ip(self):
|
||||
cmd = DiagnosticsRunner.build_ssh_command('10.0.0.1', 'eth0')
|
||||
|
||||
Reference in New Issue
Block a user