feat: terminal aesthetic rewrite + link debug page
- Full dark terminal aesthetic (Pulse/TinkerTickets style): - #0a0a0a background, #00ff41 green, #ffb000 amber, #00ffff cyan - CRT scanline overlay, phosphor glow, ASCII corner pseudoelements - Bracket-notation badges [CRITICAL], monospace font throughout - style.css, base.html, index.html, suppressions.html all rewritten - New Link Debug page (/links, /api/links): - Per-host, per-interface cards with speed/duplex/port type/auto-neg - Traffic bars (TX cyan, RX green) with rate labels - Error/drop counters, carrier change history - SFP/DOM optical panel: vendor, temp, voltage, bias, TX/RX power dBm bars - RX-TX delta shown; color-coded warn/crit thresholds - Auto-refresh every 60s, anchor-jump to #hostname - LinkStatsCollector in monitor.py: - SSHes to each host (one connection, all ifaces batched) - Parses ethtool + ethtool -m (SFP DOM) output - Merges with Prometheus traffic/error/carrier metrics - Stores as link_stats in monitor_state table - config.json: added ssh section for ethtool collection - app.js: terminal chip style consistency (uppercase, ● bullet) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
19
app.py
19
app.py
@@ -96,6 +96,13 @@ def index():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/links')
|
||||||
|
@require_auth
|
||||||
|
def links_page():
|
||||||
|
user = _get_user()
|
||||||
|
return render_template('links.html', user=user)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/suppressions')
|
@app.route('/suppressions')
|
||||||
@require_auth
|
@require_auth
|
||||||
def suppressions_page():
|
def suppressions_page():
|
||||||
@@ -139,6 +146,18 @@ def api_network():
|
|||||||
return jsonify({'hosts': {}, 'unifi': [], 'updated': None})
|
return jsonify({'hosts': {}, 'unifi': [], 'updated': None})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/links')
|
||||||
|
@require_auth
|
||||||
|
def api_links():
|
||||||
|
raw = db.get_state('link_stats')
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
return jsonify(json.loads(raw))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return jsonify({'hosts': {}, 'updated': None})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/events')
|
@app.route('/api/events')
|
||||||
@require_auth
|
@require_auth
|
||||||
def api_events():
|
def api_events():
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"ssh": {
|
||||||
|
"user": "root",
|
||||||
|
"password": "Server#980000Panda",
|
||||||
|
"connect_timeout": 5,
|
||||||
|
"timeout": 20
|
||||||
|
},
|
||||||
"unifi": {
|
"unifi": {
|
||||||
"controller": "https://10.10.10.1",
|
"controller": "https://10.10.10.1",
|
||||||
"api_key": "kyPfIsAVie3hwMD4Bc1MjAu8N7HVPIb8",
|
"api_key": "kyPfIsAVie3hwMD4Bc1MjAu8N7HVPIb8",
|
||||||
|
|||||||
292
monitor.py
292
monitor.py
@@ -162,6 +162,288 @@ class TicketClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Link stats collector (ethtool + Prometheus traffic metrics)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
class LinkStatsCollector:
|
||||||
|
"""Collects detailed per-interface statistics via SSH (ethtool) and Prometheus."""
|
||||||
|
|
||||||
|
def __init__(self, cfg: dict, prom: 'PrometheusClient'):
|
||||||
|
self.prom = prom
|
||||||
|
ssh = cfg.get('ssh', {})
|
||||||
|
self.ssh_user = ssh.get('user', 'root')
|
||||||
|
self.ssh_pass = ssh.get('password', '')
|
||||||
|
self.ssh_connect_timeout = ssh.get('connect_timeout', 5)
|
||||||
|
self.ssh_timeout = ssh.get('timeout', 20)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# SSH collection
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _ssh_batch(self, ip: str, ifaces: List[str]) -> Dict[str, dict]:
|
||||||
|
"""
|
||||||
|
Open one SSH session to *ip* and collect ethtool + SFP DOM data for
|
||||||
|
all *ifaces*. Returns {iface: {speed_mbps, duplex, ..., sfp: {...}}}.
|
||||||
|
"""
|
||||||
|
if not ifaces or not self.ssh_pass:
|
||||||
|
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)]
|
||||||
|
if not safe_ifaces:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Build a single shell command: for each iface output ethtool + -m with sentinels
|
||||||
|
parts = []
|
||||||
|
for iface in safe_ifaces:
|
||||||
|
parts.append(
|
||||||
|
f'echo "___IFACE:{iface}___";'
|
||||||
|
f' ethtool "{iface}" 2>/dev/null;'
|
||||||
|
f' echo "___DOM:{iface}___";'
|
||||||
|
f' ethtool -m "{iface}" 2>/dev/null;'
|
||||||
|
f' echo "___END___"'
|
||||||
|
)
|
||||||
|
shell_cmd = ' '.join(parts)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
'sshpass', '-p', self.ssh_pass,
|
||||||
|
'ssh',
|
||||||
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-o', f'ConnectTimeout={self.ssh_connect_timeout}',
|
||||||
|
'-o', 'LogLevel=ERROR',
|
||||||
|
'-o', 'BatchMode=no',
|
||||||
|
f'{self.ssh_user}@{ip}',
|
||||||
|
shell_cmd,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=self.ssh_timeout,
|
||||||
|
)
|
||||||
|
output = result.stdout
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.debug('sshpass not found – skipping ethtool collection')
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f'SSH ethtool {ip}: {e}')
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return self._parse_ssh_output(output)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ssh_output(output: str) -> Dict[str, dict]:
|
||||||
|
result: Dict[str, dict] = {}
|
||||||
|
current_iface: Optional[str] = None
|
||||||
|
current_section: Optional[str] = None
|
||||||
|
buf: List[str] = []
|
||||||
|
|
||||||
|
def flush(iface, section, lines):
|
||||||
|
if not iface or not lines:
|
||||||
|
return
|
||||||
|
text = '\n'.join(lines)
|
||||||
|
if section == 'ethtool':
|
||||||
|
result.setdefault(iface, {}).update(
|
||||||
|
LinkStatsCollector._parse_ethtool(text)
|
||||||
|
)
|
||||||
|
elif section == 'dom':
|
||||||
|
sfp = LinkStatsCollector._parse_ethtool_m(text)
|
||||||
|
if sfp:
|
||||||
|
result.setdefault(iface, {})['sfp'] = sfp
|
||||||
|
|
||||||
|
for line in output.splitlines():
|
||||||
|
if line.startswith('___IFACE:') and line.endswith('___'):
|
||||||
|
flush(current_iface, current_section, buf)
|
||||||
|
current_iface = line[9:-3]
|
||||||
|
current_section = 'ethtool'
|
||||||
|
buf = []
|
||||||
|
elif line.startswith('___DOM:') and line.endswith('___'):
|
||||||
|
flush(current_iface, current_section, buf)
|
||||||
|
current_iface = line[7:-3]
|
||||||
|
current_section = 'dom'
|
||||||
|
buf = []
|
||||||
|
elif line == '___END___':
|
||||||
|
flush(current_iface, current_section, buf)
|
||||||
|
current_iface = None
|
||||||
|
current_section = None
|
||||||
|
buf = []
|
||||||
|
else:
|
||||||
|
buf.append(line)
|
||||||
|
|
||||||
|
flush(current_iface, current_section, buf)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ethtool(output: str) -> dict:
|
||||||
|
data: dict = {}
|
||||||
|
for line in output.splitlines():
|
||||||
|
if ':' not in line:
|
||||||
|
continue
|
||||||
|
key, _, val = line.partition(':')
|
||||||
|
key = key.strip()
|
||||||
|
val = val.strip()
|
||||||
|
if key == 'Speed':
|
||||||
|
m = re.match(r'(\d+)Mb/s', val)
|
||||||
|
if m:
|
||||||
|
data['speed_mbps'] = int(m.group(1))
|
||||||
|
elif key == 'Duplex':
|
||||||
|
data['duplex'] = val.lower()
|
||||||
|
elif key == 'Port':
|
||||||
|
data['port_type'] = val
|
||||||
|
elif key == 'Auto-negotiation':
|
||||||
|
data['auto_neg'] = (val.lower() == 'on')
|
||||||
|
elif key == 'Link detected':
|
||||||
|
data['link_detected'] = (val.lower() == 'yes')
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ethtool_m(output: str) -> dict:
|
||||||
|
"""Parse ethtool -m (SFP DOM / digital optical monitoring) output."""
|
||||||
|
if not output:
|
||||||
|
return {}
|
||||||
|
# Skip if module diagnostics unsupported
|
||||||
|
lower = output.lower()
|
||||||
|
if 'cannot get' in lower or 'not supported' in lower or 'no sfp' in lower:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
data: dict = {}
|
||||||
|
for line in output.splitlines():
|
||||||
|
if ':' not in line:
|
||||||
|
continue
|
||||||
|
key, _, val = line.partition(':')
|
||||||
|
key = key.strip()
|
||||||
|
val = val.strip()
|
||||||
|
|
||||||
|
if key == 'Vendor name':
|
||||||
|
data['vendor'] = val
|
||||||
|
elif key == 'Vendor PN':
|
||||||
|
data['part_no'] = val
|
||||||
|
elif key == 'Identifier':
|
||||||
|
m = re.search(r'\((.+?)\)', val)
|
||||||
|
if m:
|
||||||
|
data['sfp_type'] = m.group(1)
|
||||||
|
elif key == 'Connector':
|
||||||
|
m = re.search(r'\((.+?)\)', val)
|
||||||
|
if m:
|
||||||
|
data['connector'] = m.group(1)
|
||||||
|
elif key == 'Laser wavelength':
|
||||||
|
m = re.match(r'(\d+)', val)
|
||||||
|
if m:
|
||||||
|
data['wavelength_nm'] = int(m.group(1))
|
||||||
|
elif key == 'Laser bias current':
|
||||||
|
# e.g. "4.340 mA"
|
||||||
|
m = re.match(r'([\d.]+)\s+mA', val)
|
||||||
|
if m:
|
||||||
|
data['bias_ma'] = float(m.group(1))
|
||||||
|
elif key == 'Laser output power':
|
||||||
|
# e.g. "0.1234 mW / -9.09 dBm"
|
||||||
|
m = re.search(r'/\s*([-\d.]+)\s*dBm', val)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
data['tx_power_dbm'] = float(m.group(1))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif 'receiver' in key.lower() and ('power' in key.lower() or 'optical' in key.lower()):
|
||||||
|
m = re.search(r'/\s*([-\d.]+)\s*dBm', val)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
data['rx_power_dbm'] = float(m.group(1))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif key == 'Module temperature':
|
||||||
|
# e.g. "36.00 degrees C / 96.80 degrees F"
|
||||||
|
m = re.match(r'([\d.]+)\s+degrees', val)
|
||||||
|
if m:
|
||||||
|
data['temp_c'] = float(m.group(1))
|
||||||
|
elif key == 'Module voltage':
|
||||||
|
# e.g. "3.3180 V"
|
||||||
|
m = re.match(r'([\d.]+)\s+V', val)
|
||||||
|
if m:
|
||||||
|
data['voltage_v'] = float(m.group(1))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Prometheus traffic / error metrics
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _collect_prom_metrics(self) -> Dict[str, Dict[str, dict]]:
|
||||||
|
"""Return {instance: {device: {tx_bytes_rate, rx_bytes_rate, ...}}}."""
|
||||||
|
metrics: Dict[str, Dict[str, dict]] = {}
|
||||||
|
|
||||||
|
queries = [
|
||||||
|
('tx_bytes_rate', 'rate(node_network_transmit_bytes_total[5m])'),
|
||||||
|
('rx_bytes_rate', 'rate(node_network_receive_bytes_total[5m])'),
|
||||||
|
('tx_errs_rate', 'rate(node_network_transmit_errs_total[5m])'),
|
||||||
|
('rx_errs_rate', 'rate(node_network_receive_errs_total[5m])'),
|
||||||
|
('tx_drops_rate', 'rate(node_network_transmit_drop_total[5m])'),
|
||||||
|
('rx_drops_rate', 'rate(node_network_receive_drop_total[5m])'),
|
||||||
|
('carrier_changes', 'node_network_carrier_changes_total'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for field, promql in queries:
|
||||||
|
for r in self.prom.query(promql):
|
||||||
|
instance = r['metric'].get('instance', '')
|
||||||
|
device = r['metric'].get('device', '')
|
||||||
|
if not is_physical_interface(device):
|
||||||
|
continue
|
||||||
|
raw = r['value'][1]
|
||||||
|
try:
|
||||||
|
val: Optional[float] = float(raw)
|
||||||
|
if val != val: # NaN
|
||||||
|
val = None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
val = None
|
||||||
|
metrics.setdefault(instance, {}).setdefault(device, {})[field] = val
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Main collection entry point
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def collect(self, instance_map: Dict[str, str]) -> dict:
|
||||||
|
"""
|
||||||
|
Collect full link stats for all Prometheus-monitored hosts.
|
||||||
|
|
||||||
|
*instance_map*: ``{'10.10.10.2:9100': 'large1', ...}``
|
||||||
|
|
||||||
|
Returns a dict suitable for ``db.set_state('link_stats', ...)``.
|
||||||
|
"""
|
||||||
|
prom_metrics = self._collect_prom_metrics()
|
||||||
|
result_hosts: Dict[str, Dict[str, dict]] = {}
|
||||||
|
|
||||||
|
for instance, iface_metrics in prom_metrics.items():
|
||||||
|
host = instance_map.get(instance, instance.split(':')[0])
|
||||||
|
host_ip = instance.split(':')[0]
|
||||||
|
ifaces = list(iface_metrics.keys())
|
||||||
|
|
||||||
|
# SSH ethtool collection (one connection per host, all ifaces)
|
||||||
|
ethtool_data: Dict[str, dict] = {}
|
||||||
|
if self.ssh_pass and ifaces:
|
||||||
|
try:
|
||||||
|
ethtool_data = self._ssh_batch(host_ip, ifaces)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f'ethtool collection failed for {host} ({host_ip}): {e}')
|
||||||
|
|
||||||
|
# Merge Prometheus metrics + ethtool data per interface
|
||||||
|
merged: Dict[str, dict] = {}
|
||||||
|
for iface in ifaces:
|
||||||
|
d: dict = {'host_ip': host_ip}
|
||||||
|
d.update(iface_metrics.get(iface, {}))
|
||||||
|
eth = ethtool_data.get(iface, {})
|
||||||
|
for k, v in eth.items():
|
||||||
|
if k != 'sfp':
|
||||||
|
d[k] = v
|
||||||
|
if 'sfp' in eth:
|
||||||
|
d['sfp'] = eth['sfp']
|
||||||
|
merged[iface] = d
|
||||||
|
|
||||||
|
result_hosts[host] = merged
|
||||||
|
|
||||||
|
return {
|
||||||
|
'hosts': result_hosts,
|
||||||
|
'updated': datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -197,6 +479,7 @@ class NetworkMonitor:
|
|||||||
self.prom = PrometheusClient(prom_url)
|
self.prom = PrometheusClient(prom_url)
|
||||||
self.unifi = UnifiClient(self.cfg['unifi'])
|
self.unifi = UnifiClient(self.cfg['unifi'])
|
||||||
self.tickets = TicketClient(self.cfg.get('ticket_api', {}))
|
self.tickets = TicketClient(self.cfg.get('ticket_api', {}))
|
||||||
|
self.link_stats = LinkStatsCollector(self.cfg, self.prom)
|
||||||
|
|
||||||
mon = self.cfg.get('monitor', {})
|
mon = self.cfg.get('monitor', {})
|
||||||
self.poll_interval = mon.get('poll_interval', 120)
|
self.poll_interval = mon.get('poll_interval', 120)
|
||||||
@@ -457,7 +740,14 @@ class NetworkMonitor:
|
|||||||
db.set_state('network_snapshot', snapshot)
|
db.set_state('network_snapshot', snapshot)
|
||||||
db.set_state('last_check', _now_utc())
|
db.set_state('last_check', _now_utc())
|
||||||
|
|
||||||
# 2. Process alerts (separate Prometheus call for fresh data)
|
# 2. Collect link stats (ethtool + traffic metrics)
|
||||||
|
try:
|
||||||
|
link_data = self.link_stats.collect(self._instance_map)
|
||||||
|
db.set_state('link_stats', link_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Link stats collection failed: {e}', exc_info=True)
|
||||||
|
|
||||||
|
# 3. Process alerts (separate Prometheus call for fresh data)
|
||||||
iface_states = self.prom.get_interface_states()
|
iface_states = self.prom.get_interface_states()
|
||||||
self._process_interfaces(iface_states)
|
self._process_interfaces(iface_states)
|
||||||
|
|
||||||
|
|||||||
@@ -42,13 +42,13 @@ function updateStatusBar(summary, lastCheck) {
|
|||||||
const bar = document.querySelector('.status-chips');
|
const bar = document.querySelector('.status-chips');
|
||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
const chips = [];
|
const chips = [];
|
||||||
if (summary.critical) chips.push(`<span class="chip chip-critical">⬤ ${summary.critical} Critical</span>`);
|
if (summary.critical) chips.push(`<span class="chip chip-critical">● ${summary.critical} CRITICAL</span>`);
|
||||||
if (summary.warning) chips.push(`<span class="chip chip-warning">⬤ ${summary.warning} Warning</span>`);
|
if (summary.warning) chips.push(`<span class="chip chip-warning">● ${summary.warning} WARNING</span>`);
|
||||||
if (!summary.critical && !summary.warning) chips.push('<span class="chip chip-ok">✔ All systems nominal</span>');
|
if (!summary.critical && !summary.warning) chips.push('<span class="chip chip-ok">✔ ALL SYSTEMS NOMINAL</span>');
|
||||||
bar.innerHTML = chips.join('');
|
bar.innerHTML = chips.join('');
|
||||||
|
|
||||||
const lc = document.getElementById('last-check');
|
const lc = document.getElementById('last-check');
|
||||||
if (lc && lastCheck) lc.textContent = `Last check: ${lastCheck}`;
|
if (lc && lastCheck) lc.textContent = lastCheck;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHostGrid(hosts) {
|
function updateHostGrid(hosts) {
|
||||||
@@ -157,15 +157,17 @@ function updateEventsTable(events) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
wrap.innerHTML = `
|
wrap.innerHTML = `
|
||||||
|
<div class="table-wrap">
|
||||||
<table class="data-table" id="events-table">
|
<table class="data-table" id="events-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Severity</th><th>Type</th><th>Target</th><th>Detail</th>
|
<th>Sev</th><th>Type</th><th>Target</th><th>Detail</th>
|
||||||
<th>Description</th><th>First Seen</th><th>Failures</th><th>Ticket</th><th>Actions</th>
|
<th>Description</th><th>First Seen</th><th>Failures</th><th>Ticket</th><th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>${rows}</tbody>
|
<tbody>${rows}</tbody>
|
||||||
</table>`;
|
</table>
|
||||||
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Suppression modal (dashboard) ────────────────────────────────────
|
// ── Suppression modal (dashboard) ────────────────────────────────────
|
||||||
|
|||||||
1208
static/style.css
1208
static/style.css
File diff suppressed because it is too large
Load Diff
@@ -7,24 +7,31 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar">
|
<header class="header">
|
||||||
<div class="nav-brand">
|
<div class="header-left">
|
||||||
<span class="nav-logo">⚡</span>
|
<div class="header-brand">
|
||||||
<span class="nav-title">GANDALF</span>
|
<span class="header-title">GANDALF</span>
|
||||||
<span class="nav-sub">Network Monitor</span>
|
<span class="header-sub">Network Monitor // LotusGuild</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-links">
|
<nav class="header-nav">
|
||||||
<a href="{{ url_for('index') }}" class="nav-link {% if request.endpoint == 'index' %}active{% endif %}">
|
<a href="{{ url_for('index') }}"
|
||||||
|
class="nav-link {% if request.endpoint == 'index' %}active{% endif %}">
|
||||||
Dashboard
|
Dashboard
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('suppressions_page') }}" class="nav-link {% if request.endpoint == 'suppressions_page' %}active{% endif %}">
|
<a href="{{ url_for('links_page') }}"
|
||||||
|
class="nav-link {% if request.endpoint == 'links_page' %}active{% endif %}">
|
||||||
|
Link Debug
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('suppressions_page') }}"
|
||||||
|
class="nav-link {% if request.endpoint == 'suppressions_page' %}active{% endif %}">
|
||||||
Suppressions
|
Suppressions
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
<div class="nav-user">
|
|
||||||
<span class="nav-user-name">{{ user.name or user.username }}</span>
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<span class="header-user">{{ user.name or user.username }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|||||||
@@ -3,33 +3,37 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<!-- ── Status bar ─────────────────────────────────────────────────────── -->
|
<!-- ── Status bar ──────────────────────────────────────────────────── -->
|
||||||
<div class="status-bar">
|
<div class="status-bar">
|
||||||
<div class="status-chips">
|
<div class="status-chips">
|
||||||
{% if summary.critical %}
|
{% if summary.critical %}
|
||||||
<span class="chip chip-critical">⬤ {{ summary.critical }} Critical</span>
|
<span class="chip chip-critical">● {{ summary.critical }} CRITICAL</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if summary.warning %}
|
{% if summary.warning %}
|
||||||
<span class="chip chip-warning">⬤ {{ summary.warning }} Warning</span>
|
<span class="chip chip-warning">● {{ summary.warning }} WARNING</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not summary.critical and not summary.warning %}
|
{% if not summary.critical and not summary.warning %}
|
||||||
<span class="chip chip-ok">✔ All systems nominal</span>
|
<span class="chip chip-ok">✔ ALL SYSTEMS NOMINAL</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="status-meta">
|
<div class="status-meta">
|
||||||
<span class="last-check" id="last-check">Last check: {{ last_check }}</span>
|
<span class="last-check" id="last-check">{{ last_check }}</span>
|
||||||
<button class="btn-refresh" onclick="refreshAll()">↻ Refresh</button>
|
<button class="btn-refresh" onclick="refreshAll()">↻ REFRESH</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Network topology + host grid ──────────────────────────────────── -->
|
<!-- ── Network topology + host grid ───────────────────────────────── -->
|
||||||
<section class="section">
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
<h2 class="section-title">Network Hosts</h2>
|
<h2 class="section-title">Network Hosts</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Simple topology diagram -->
|
|
||||||
<div class="topology" id="topology-diagram">
|
<div class="topology" id="topology-diagram">
|
||||||
<div class="topo-row topo-row-internet">
|
<div class="topo-row topo-row-internet">
|
||||||
<div class="topo-node topo-internet">🌐 Internet</div>
|
<div class="topo-node topo-internet">
|
||||||
|
<span class="topo-icon">◈</span>
|
||||||
|
<span class="topo-label">Internet</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topo-connectors single">
|
<div class="topo-connectors single">
|
||||||
<div class="topo-line"></div>
|
<div class="topo-line"></div>
|
||||||
@@ -87,7 +91,7 @@
|
|||||||
<span class="host-status-dot dot-{{ host.status }}"></span>
|
<span class="host-status-dot dot-{{ host.status }}"></span>
|
||||||
<span class="host-name">{{ name }}</span>
|
<span class="host-name">{{ name }}</span>
|
||||||
{% if suppressed %}
|
{% if suppressed %}
|
||||||
<span class="badge badge-suppressed" title="Suppressed">🔕</span>
|
<span class="badge-suppressed" title="Suppressed">🔕</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="host-meta">
|
<div class="host-meta">
|
||||||
@@ -107,15 +111,19 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="host-ping-note">Monitored via ping only</div>
|
<div class="host-ping-note">ping-only / no node_exporter</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="host-actions">
|
<div class="host-actions">
|
||||||
<button class="btn-sm btn-suppress"
|
<button class="btn-sm btn-suppress"
|
||||||
onclick="openSuppressModal('host', '{{ name }}', '')"
|
onclick="openSuppressModal('host', '{{ name }}', '')"
|
||||||
title="Suppress alerts for this host">
|
title="Suppress alerts for this host">
|
||||||
🔕 Suppress Host
|
🔕 Suppress
|
||||||
</button>
|
</button>
|
||||||
|
<a href="{{ url_for('links_page') }}#{{ name }}"
|
||||||
|
class="btn-sm btn-secondary" style="text-decoration:none">
|
||||||
|
↗ Links
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -124,10 +132,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── UniFi devices ──────────────────────────────────────────────────── -->
|
<!-- ── UniFi devices ────────────────────────────────────────────────── -->
|
||||||
{% if snapshot.unifi %}
|
{% if snapshot.unifi %}
|
||||||
<section class="section">
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
<h2 class="section-title">UniFi Devices</h2>
|
<h2 class="section-title">UniFi Devices</h2>
|
||||||
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table" id="unifi-table">
|
<table class="data-table" id="unifi-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -145,7 +155,7 @@
|
|||||||
<tr class="{% if not d.connected %}row-critical{% endif %}">
|
<tr class="{% if not d.connected %}row-critical{% endif %}">
|
||||||
<td>
|
<td>
|
||||||
<span class="dot-{{ 'up' if d.connected else 'down' }}"></span>
|
<span class="dot-{{ 'up' if d.connected else 'down' }}"></span>
|
||||||
{{ 'Online' if d.connected else 'Offline' }}
|
{{ 'ONLINE' if d.connected else 'OFFLINE' }}
|
||||||
</td>
|
</td>
|
||||||
<td><strong>{{ d.name }}</strong></td>
|
<td><strong>{{ d.name }}</strong></td>
|
||||||
<td>{{ d.type }}</td>
|
<td>{{ d.type }}</td>
|
||||||
@@ -167,20 +177,21 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- ── Active alerts ─────────────────────────────────────────────────── -->
|
<!-- ── Active alerts ───────────────────────────────────────────────── -->
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h2 class="section-title">
|
<div class="section-header">
|
||||||
Active Alerts
|
<h2 class="section-title">Active Alerts</h2>
|
||||||
{% if summary.critical or summary.warning %}
|
{% if summary.critical or summary.warning %}
|
||||||
<span class="section-badge badge-critical">{{ (summary.critical or 0) + (summary.warning or 0) }} open</span>
|
<span class="section-badge">{{ (summary.critical or 0) + (summary.warning or 0) }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h2>
|
</div>
|
||||||
<div class="table-wrap" id="events-table-wrap">
|
<div id="events-table-wrap">
|
||||||
{% if events %}
|
{% if events %}
|
||||||
|
<div class="table-wrap">
|
||||||
<table class="data-table" id="events-table">
|
<table class="data-table" id="events-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Severity</th>
|
<th>Sev</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Target</th>
|
<th>Target</th>
|
||||||
<th>Detail</th>
|
<th>Detail</th>
|
||||||
@@ -211,26 +222,23 @@
|
|||||||
<td>
|
<td>
|
||||||
<button class="btn-sm btn-suppress"
|
<button class="btn-sm btn-suppress"
|
||||||
onclick="openSuppressModal('{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}', '{{ e.target_name }}', '{{ e.target_detail or '' }}')"
|
onclick="openSuppressModal('{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}', '{{ e.target_name }}', '{{ e.target_detail or '' }}')"
|
||||||
title="Suppress this alert">
|
title="Suppress">🔕</button>
|
||||||
🔕
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr class="empty-row">
|
<tr><td colspan="9" class="empty-state">No active alerts ✔</td></tr>
|
||||||
<td colspan="9" class="empty-state">No active alerts ✔</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty-state">No active alerts ✔</p>
|
<p class="empty-state">No active alerts ✔</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── Quick-suppress modal ───────────────────────────────────────────── -->
|
<!-- ── Quick-suppress modal ─────────────────────────────────────────── -->
|
||||||
<div id="suppress-modal" class="modal-overlay" style="display:none">
|
<div id="suppress-modal" class="modal-overlay" style="display:none">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -238,42 +246,43 @@
|
|||||||
<button class="modal-close" onclick="closeSuppressModal()">✕</button>
|
<button class="modal-close" onclick="closeSuppressModal()">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="suppress-form" onsubmit="submitSuppress(event)">
|
<form id="suppress-form" onsubmit="submitSuppress(event)">
|
||||||
<div class="form-group">
|
<div class="form-group" style="margin-bottom:10px">
|
||||||
<label>Target Type</label>
|
<label>Target Type</label>
|
||||||
<select id="sup-type" name="target_type" onchange="updateSuppressForm()">
|
<select id="sup-type" name="target_type" onchange="updateSuppressForm()">
|
||||||
<option value="host">Host (all interfaces)</option>
|
<option value="host">Host (all interfaces)</option>
|
||||||
<option value="interface">Specific Interface</option>
|
<option value="interface">Specific Interface</option>
|
||||||
<option value="unifi_device">UniFi Device</option>
|
<option value="unifi_device">UniFi Device</option>
|
||||||
<option value="all">Everything (global maintenance)</option>
|
<option value="all">Global Maintenance</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="sup-name-group">
|
<div class="form-group" id="sup-name-group" style="margin-bottom:10px">
|
||||||
<label>Target Name</label>
|
<label>Target Name</label>
|
||||||
<input type="text" id="sup-name" name="target_name" placeholder="e.g. large1">
|
<input type="text" id="sup-name" name="target_name" placeholder="e.g. large1">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="sup-detail-group">
|
<div class="form-group" id="sup-detail-group" style="margin-bottom:10px;display:none">
|
||||||
<label>Interface Name <span class="form-hint">(for interface type)</span></label>
|
<label>Interface <span class="form-hint">(interface type only)</span></label>
|
||||||
<input type="text" id="sup-detail" name="target_detail" placeholder="e.g. enp35s0">
|
<input type="text" id="sup-detail" name="target_detail" placeholder="e.g. enp35s0">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group" style="margin-bottom:10px">
|
||||||
<label>Reason <span class="required">*</span></label>
|
<label>Reason <span class="required">*</span></label>
|
||||||
<input type="text" id="sup-reason" name="reason" placeholder="e.g. Planned switch reboot" required>
|
<input type="text" id="sup-reason" name="reason"
|
||||||
|
placeholder="e.g. Planned switch reboot" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group" style="margin-bottom:0">
|
||||||
<label>Duration</label>
|
<label>Duration</label>
|
||||||
<div class="duration-pills">
|
<div class="duration-pills">
|
||||||
<button type="button" class="pill" onclick="setDuration(30)">30 min</button>
|
<button type="button" class="pill" onclick="setDuration(30)">30 min</button>
|
||||||
<button type="button" class="pill" onclick="setDuration(60)">1 hr</button>
|
<button type="button" class="pill" onclick="setDuration(60)">1 hr</button>
|
||||||
<button type="button" class="pill" onclick="setDuration(240)">4 hr</button>
|
<button type="button" class="pill" onclick="setDuration(240)">4 hr</button>
|
||||||
<button type="button" class="pill" onclick="setDuration(480)">8 hr</button>
|
<button type="button" class="pill" onclick="setDuration(480)">8 hr</button>
|
||||||
<button type="button" class="pill pill-manual active" onclick="setDuration(null)">Manual</button>
|
<button type="button" class="pill pill-manual active" onclick="setDuration(null)">Manual ∞</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="sup-expires" name="expires_minutes" value="">
|
<input type="hidden" id="sup-expires" name="expires_minutes" value="">
|
||||||
<div class="form-hint" id="duration-hint">Suppression will persist until manually removed.</div>
|
<div class="form-hint" id="duration-hint">Persists until manually removed.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeSuppressModal()">Cancel</button>
|
<button type="button" class="btn btn-secondary" onclick="closeSuppressModal()">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary">Apply Suppression</button>
|
<button type="submit" class="btn btn-primary">Apply</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,7 +292,6 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
// Auto-refresh every 30 seconds
|
|
||||||
setInterval(refreshAll, 30000);
|
setInterval(refreshAll, 30000);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
323
templates/links.html
Normal file
323
templates/links.html
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Link Debug – GANDALF{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Link Debug</h1>
|
||||||
|
<p class="page-sub">
|
||||||
|
Per-interface stats: speed, duplex, SFP optical levels, TX/RX rates, errors, and carrier changes.
|
||||||
|
Data collected via Prometheus node_exporter + SSH ethtool every poll cycle.
|
||||||
|
<span id="links-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="links-container">
|
||||||
|
<div class="link-loading">Loading link statistics</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// ── Formatting helpers ────────────────────────────────────────────
|
||||||
|
function fmtRate(bytesPerSec) {
|
||||||
|
if (bytesPerSec === null || bytesPerSec === undefined) return '–';
|
||||||
|
const bps = bytesPerSec * 8;
|
||||||
|
if (bps < 1e3) return bps.toFixed(0) + ' bps';
|
||||||
|
if (bps < 1e6) return (bps/1e3).toFixed(1) + ' Kbps';
|
||||||
|
if (bps < 1e9) return (bps/1e6).toFixed(2) + ' Mbps';
|
||||||
|
return (bps/1e9).toFixed(3) + ' Gbps';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtRateBar(bytesPerSec, linkSpeedMbps) {
|
||||||
|
if (!linkSpeedMbps || linkSpeedMbps <= 0) return 0;
|
||||||
|
const mbps = (bytesPerSec * 8) / 1e6;
|
||||||
|
return Math.min(100, (mbps / linkSpeedMbps) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSpeed(mbps) {
|
||||||
|
if (mbps === null || mbps === undefined) return '–';
|
||||||
|
if (mbps >= 1000) return (mbps/1000).toFixed(0) + ' Gbps';
|
||||||
|
return mbps + ' Mbps';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuplex(d) {
|
||||||
|
if (!d) return '–';
|
||||||
|
return d.charAt(0).toUpperCase() + d.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTemp(c) {
|
||||||
|
if (c === null || c === undefined) return '–';
|
||||||
|
return c.toFixed(1) + '°C';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtVoltage(v) {
|
||||||
|
if (v === null || v === undefined) return '–';
|
||||||
|
return v.toFixed(2) + 'V';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPower(dbm) {
|
||||||
|
if (dbm === null || dbm === undefined) return '–';
|
||||||
|
return dbm.toFixed(2) + ' dBm';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtBias(ma) {
|
||||||
|
if (ma === null || ma === undefined) return '–';
|
||||||
|
return ma.toFixed(2) + ' mA';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtErrors(rate) {
|
||||||
|
if (rate === null || rate === undefined) return '–';
|
||||||
|
if (rate < 0.001) return '<span class="counter-zero">0 /s</span>';
|
||||||
|
return `<span class="counter-nonzero">${rate.toFixed(3)} /s</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCarrier(n) {
|
||||||
|
if (n === null || n === undefined) return '–';
|
||||||
|
const v = parseInt(n);
|
||||||
|
if (v <= 2) return `<span class="val-good">${v}</span>`;
|
||||||
|
if (v <= 10) return `<span class="val-warn">${v}</span>`;
|
||||||
|
return `<span class="val-crit">${v}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Power level: returns {cls, pct} for -30..0 dBm scale
|
||||||
|
function rxPowerClass(dbm) {
|
||||||
|
if (dbm === null || dbm === undefined) return {cls:'power-ok', pct:0};
|
||||||
|
const pct = Math.max(0, Math.min(100, (dbm + 30) / 30 * 100));
|
||||||
|
let cls = 'power-ok';
|
||||||
|
if (dbm < -25 || dbm > 0) cls = 'power-crit';
|
||||||
|
else if (dbm < -20) cls = 'power-warn';
|
||||||
|
return {cls, pct};
|
||||||
|
}
|
||||||
|
|
||||||
|
function txPowerClass(dbm) {
|
||||||
|
if (dbm === null || dbm === undefined) return {cls:'power-ok', pct:0};
|
||||||
|
const pct = Math.max(0, Math.min(100, (dbm + 20) / 20 * 100));
|
||||||
|
let cls = 'power-ok';
|
||||||
|
if (dbm < -15 || dbm > 2) cls = 'power-crit';
|
||||||
|
else if (dbm < -10) cls = 'power-warn';
|
||||||
|
return {cls, pct};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tempClass(c) {
|
||||||
|
if (c === null || c === undefined) return 'val-neutral';
|
||||||
|
if (c > 80) return 'val-crit';
|
||||||
|
if (c > 60) return 'val-warn';
|
||||||
|
return 'val-good';
|
||||||
|
}
|
||||||
|
|
||||||
|
function voltageClass(v) {
|
||||||
|
if (v === null || v === undefined) return 'val-neutral';
|
||||||
|
if (v < 3.0 || v > 3.6) return 'val-crit';
|
||||||
|
if (v < 3.1 || v > 3.5) return 'val-warn';
|
||||||
|
return 'val-good';
|
||||||
|
}
|
||||||
|
|
||||||
|
function portTypeLabel(pt) {
|
||||||
|
if (!pt) return {label:'–', cls:''};
|
||||||
|
const u = pt.toUpperCase();
|
||||||
|
if (u.includes('FIBRE') || u.includes('FIBER') || u.includes('SFP'))
|
||||||
|
return {label: pt, cls: 'type-fibre'};
|
||||||
|
if (u.includes('DA') || u.includes('DIRECT'))
|
||||||
|
return {label: pt, cls: 'type-da'};
|
||||||
|
return {label: pt, cls: 'type-copper'};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render a single interface card ────────────────────────────────
|
||||||
|
function renderIfaceCard(ifaceName, d) {
|
||||||
|
const speed = fmtSpeed(d.speed_mbps);
|
||||||
|
const duplex = fmtDuplex(d.duplex);
|
||||||
|
const ptype = portTypeLabel(d.port_type);
|
||||||
|
const autoneg= d.auto_neg !== undefined ? (d.auto_neg ? 'On' : 'Off') : '–';
|
||||||
|
const linkDet= d.link_detected !== undefined ? (d.link_detected ? '<span class="val-good">Yes</span>' : '<span class="val-crit">No</span>') : '–';
|
||||||
|
|
||||||
|
// Traffic bars
|
||||||
|
const txRate = d.tx_bytes_rate;
|
||||||
|
const rxRate = d.rx_bytes_rate;
|
||||||
|
const txPct = fmtRateBar(txRate, d.speed_mbps);
|
||||||
|
const rxPct = fmtRateBar(rxRate, d.speed_mbps);
|
||||||
|
|
||||||
|
const txStr = fmtRate(txRate);
|
||||||
|
const rxStr = fmtRate(rxRate);
|
||||||
|
|
||||||
|
// SFP / optical section
|
||||||
|
let sfpHtml = '';
|
||||||
|
const sfp = d.sfp;
|
||||||
|
if (sfp && Object.keys(sfp).length > 0) {
|
||||||
|
const tx = txPowerClass(sfp.tx_power_dbm);
|
||||||
|
const rx = rxPowerClass(sfp.rx_power_dbm);
|
||||||
|
const tcls = tempClass(sfp.temp_c);
|
||||||
|
const vcls = voltageClass(sfp.voltage_v);
|
||||||
|
|
||||||
|
const vendorStr = [sfp.vendor, sfp.part_no].filter(Boolean).join(' / ') || '–';
|
||||||
|
const sfpTypeStr= [sfp.sfp_type, sfp.connector, sfp.wavelength_nm ? sfp.wavelength_nm + 'nm' : ''].filter(Boolean).join(' · ') || '';
|
||||||
|
|
||||||
|
sfpHtml = `
|
||||||
|
<div class="sfp-panel">
|
||||||
|
<div class="sfp-vendor-row">
|
||||||
|
<span>${escHtml(vendorStr)}</span>
|
||||||
|
${sfpTypeStr ? `<span style="margin-left:8px;color:var(--text-muted)">${escHtml(sfpTypeStr)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="sfp-grid">
|
||||||
|
<div class="sfp-stat">
|
||||||
|
<span class="sfp-stat-label">Temp</span>
|
||||||
|
<span class="sfp-stat-value ${tcls}">${fmtTemp(sfp.temp_c)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sfp-stat">
|
||||||
|
<span class="sfp-stat-label">Voltage</span>
|
||||||
|
<span class="sfp-stat-value ${vcls}">${fmtVoltage(sfp.voltage_v)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sfp-stat">
|
||||||
|
<span class="sfp-stat-label">Bias</span>
|
||||||
|
<span class="sfp-stat-value">${fmtBias(sfp.bias_ma)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sfp-stat">
|
||||||
|
<span class="sfp-stat-label">TX Power</span>
|
||||||
|
<span class="sfp-stat-value ${tx.cls === 'power-ok' ? 'val-good' : tx.cls === 'power-warn' ? 'val-warn' : 'val-crit'}">${fmtPower(sfp.tx_power_dbm)}</span>
|
||||||
|
<div class="power-row">
|
||||||
|
<div class="power-track"><div class="power-fill ${tx.cls}" style="width:${tx.pct}%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sfp-stat">
|
||||||
|
<span class="sfp-stat-label">RX Power</span>
|
||||||
|
<span class="sfp-stat-value ${rx.cls === 'power-ok' ? 'val-good' : rx.cls === 'power-warn' ? 'val-warn' : 'val-crit'}">${fmtPower(sfp.rx_power_dbm)}</span>
|
||||||
|
<div class="power-row">
|
||||||
|
<div class="power-track"><div class="power-fill ${rx.cls}" style="width:${rx.pct}%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sfp-stat">
|
||||||
|
<span class="sfp-stat-label">RX – TX</span>
|
||||||
|
<span class="sfp-stat-value ${sfp.rx_power_dbm !== undefined && sfp.tx_power_dbm !== undefined ? (Math.abs(sfp.rx_power_dbm - sfp.tx_power_dbm) > 8 ? 'val-warn' : 'val-neutral') : 'val-neutral'}">
|
||||||
|
${sfp.rx_power_dbm !== undefined && sfp.tx_power_dbm !== undefined
|
||||||
|
? (sfp.rx_power_dbm - sfp.tx_power_dbm).toFixed(2) + ' dBm'
|
||||||
|
: '–'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="link-iface-card">
|
||||||
|
<div class="link-iface-header">
|
||||||
|
<span class="link-iface-name">${escHtml(ifaceName)}</span>
|
||||||
|
${speed !== '–' ? `<span class="link-iface-speed">${speed}</span>` : ''}
|
||||||
|
${ptype.label !== '–' ? `<span class="link-iface-type ${ptype.cls}">${escHtml(ptype.label)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="link-stats-grid">
|
||||||
|
<div class="link-stat">
|
||||||
|
<span class="link-stat-label">Duplex</span>
|
||||||
|
<span class="link-stat-value ${duplex==='Full'?'val-good':duplex==='Half'?'val-crit':'val-neutral'}">${duplex}</span>
|
||||||
|
</div>
|
||||||
|
<div class="link-stat">
|
||||||
|
<span class="link-stat-label">Auto-neg</span>
|
||||||
|
<span class="link-stat-value val-neutral">${autoneg}</span>
|
||||||
|
</div>
|
||||||
|
<div class="link-stat">
|
||||||
|
<span class="link-stat-label">Link Det.</span>
|
||||||
|
<span class="link-stat-value">${linkDet}</span>
|
||||||
|
</div>
|
||||||
|
<div class="link-stat">
|
||||||
|
<span class="link-stat-label">Carrier Chg</span>
|
||||||
|
<span class="link-stat-value">${fmtCarrier(d.carrier_changes)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="link-stat">
|
||||||
|
<span class="link-stat-label">TX Errors</span>
|
||||||
|
<span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="link-stat">
|
||||||
|
<span class="link-stat-label">RX Errors</span>
|
||||||
|
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="link-stat">
|
||||||
|
<span class="link-stat-label">TX Drops</span>
|
||||||
|
<span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="link-stat">
|
||||||
|
<span class="link-stat-label">RX Drops</span>
|
||||||
|
<span class="link-stat-value">${fmtErrors(d.rx_drops_rate)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${(txRate !== undefined || rxRate !== undefined) ? `
|
||||||
|
<div class="traffic-section">
|
||||||
|
<div class="traffic-row">
|
||||||
|
<span class="traffic-label">TX</span>
|
||||||
|
<div class="traffic-bar-track">
|
||||||
|
<div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="traffic-value">${txStr}</span>
|
||||||
|
</div>
|
||||||
|
<div class="traffic-row">
|
||||||
|
<span class="traffic-label">RX</span>
|
||||||
|
<div class="traffic-bar-track">
|
||||||
|
<div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="traffic-value">${rxStr}</span>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
|
${sfpHtml}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render all hosts ──────────────────────────────────────────────
|
||||||
|
function renderLinks(data) {
|
||||||
|
const hosts = data.hosts || {};
|
||||||
|
if (!Object.keys(hosts).length) {
|
||||||
|
document.getElementById('links-container').innerHTML =
|
||||||
|
'<p class="empty-state">No link data collected yet. Monitor may still be initialising.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upd = data.updated ? `Updated: ${data.updated}` : '';
|
||||||
|
const updEl = document.getElementById('links-updated');
|
||||||
|
if (updEl) updEl.textContent = upd;
|
||||||
|
|
||||||
|
const html = Object.entries(hosts).map(([hostName, ifaces]) => {
|
||||||
|
const ifaceCards = Object.entries(ifaces)
|
||||||
|
.sort(([a],[b]) => a.localeCompare(b))
|
||||||
|
.map(([ifaceName, d]) => renderIfaceCard(ifaceName, d))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const hostIp = ifaces[Object.keys(ifaces)[0]]?.host_ip || '';
|
||||||
|
return `
|
||||||
|
<div class="link-host-panel" id="${escHtml(hostName)}">
|
||||||
|
<div class="link-host-title">
|
||||||
|
<span class="link-host-name">${escHtml(hostName)}</span>
|
||||||
|
${hostIp ? `<span class="link-host-ip">${escHtml(hostIp)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="link-ifaces-grid">
|
||||||
|
${ifaceCards || '<div class="link-no-data">No interface data available.</div>'}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
document.getElementById('links-container').innerHTML =
|
||||||
|
`<div class="link-host-list">${html}</div>`;
|
||||||
|
|
||||||
|
// Jump to anchor if URL has #hostname
|
||||||
|
if (location.hash) {
|
||||||
|
const el = document.querySelector(location.hash);
|
||||||
|
if (el) el.scrollIntoView({behavior:'smooth', block:'start'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch and render ──────────────────────────────────────────────
|
||||||
|
async function loadLinks() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/links');
|
||||||
|
if (!resp.ok) throw new Error('API error');
|
||||||
|
const data = await resp.json();
|
||||||
|
renderLinks(data);
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('links-container').innerHTML =
|
||||||
|
'<p class="empty-state">Failed to load link data.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLinks();
|
||||||
|
setInterval(loadLinks, 60000);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -5,13 +5,15 @@
|
|||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title">Alert Suppressions</h1>
|
<h1 class="page-title">Alert Suppressions</h1>
|
||||||
<p class="page-sub">Manage maintenance windows and alert suppression rules.</p>
|
<p class="page-sub">Manage maintenance windows and per-target alert suppression rules.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Create suppression ─────────────────────────────────────────────── -->
|
<!-- ── Create suppression ─────────────────────────────────────────── -->
|
||||||
<section class="section">
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
<h2 class="section-title">Create Suppression</h2>
|
<h2 class="section-title">Create Suppression</h2>
|
||||||
<div class="card form-card">
|
</div>
|
||||||
|
<div class="form-card">
|
||||||
<form id="create-suppression-form" onsubmit="createSuppression(event)">
|
<form id="create-suppression-form" onsubmit="createSuppression(event)">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -23,13 +25,11 @@
|
|||||||
<option value="all">Global (suppress everything)</option>
|
<option value="all">Global (suppress everything)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="name-group">
|
<div class="form-group" id="name-group">
|
||||||
<label for="s-name">Target Name <span class="required">*</span></label>
|
<label for="s-name">Target Name <span class="required">*</span></label>
|
||||||
<input type="text" id="s-name" name="target_name"
|
<input type="text" id="s-name" name="target_name"
|
||||||
placeholder="hostname or device name" autocomplete="off">
|
placeholder="hostname or device name" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="detail-group" style="display:none">
|
<div class="form-group" id="detail-group" style="display:none">
|
||||||
<label for="s-detail">Interface Name</label>
|
<label for="s-detail">Interface Name</label>
|
||||||
<input type="text" id="s-detail" name="target_detail"
|
<input type="text" id="s-detail" name="target_detail"
|
||||||
@@ -57,39 +57,29 @@
|
|||||||
<button type="button" class="pill pill-manual active" onclick="setDur(null)">Manual ∞</button>
|
<button type="button" class="pill pill-manual active" onclick="setDur(null)">Manual ∞</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="s-expires" name="expires_minutes" value="">
|
<input type="hidden" id="s-expires" name="expires_minutes" value="">
|
||||||
<div class="form-hint" id="s-dur-hint">
|
<div class="form-hint" id="s-dur-hint">Persists until manually removed.</div>
|
||||||
This suppression will persist until manually removed.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group form-group-submit">
|
<div class="form-group form-group-submit">
|
||||||
<button type="submit" class="btn btn-primary btn-lg">
|
<button type="submit" class="btn btn-primary btn-lg">🔕 Apply Suppression</button>
|
||||||
🔕 Apply Suppression
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── Active suppressions ────────────────────────────────────────────── -->
|
<!-- ── Active suppressions ────────────────────────────────────────── -->
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h2 class="section-title">
|
<div class="section-header">
|
||||||
Active Suppressions
|
<h2 class="section-title">Active Suppressions</h2>
|
||||||
<span class="section-badge">{{ active | length }}</span>
|
<span class="section-badge">{{ active | length }}</span>
|
||||||
</h2>
|
</div>
|
||||||
{% if active %}
|
{% if active %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table" id="active-sup-table">
|
<table class="data-table" id="active-sup-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Type</th>
|
<th>Type</th><th>Target</th><th>Detail</th><th>Reason</th>
|
||||||
<th>Target</th>
|
<th>By</th><th>Created</th><th>Expires</th><th>Actions</th>
|
||||||
<th>Detail</th>
|
|
||||||
<th>Reason</th>
|
|
||||||
<th>By</th>
|
|
||||||
<th>Created</th>
|
|
||||||
<th>Expires</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -101,14 +91,9 @@
|
|||||||
<td>{{ s.reason }}</td>
|
<td>{{ s.reason }}</td>
|
||||||
<td>{{ s.suppressed_by }}</td>
|
<td>{{ s.suppressed_by }}</td>
|
||||||
<td class="ts-cell">{{ s.created_at }}</td>
|
<td class="ts-cell">{{ s.created_at }}</td>
|
||||||
<td class="ts-cell">
|
<td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td>
|
||||||
{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
<button class="btn-sm btn-danger"
|
<button class="btn-sm btn-danger" onclick="removeSuppression({{ s.id }})">Remove</button>
|
||||||
onclick="removeSuppression({{ s.id }})">
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -120,22 +105,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── Suppression history ────────────────────────────────────────────── -->
|
<!-- ── Suppression history ────────────────────────────────────────── -->
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h2 class="section-title">History <span class="section-badge">{{ history | length }}</span></h2>
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">History</h2>
|
||||||
|
<span class="section-badge">{{ history | length }}</span>
|
||||||
|
</div>
|
||||||
{% if history %}
|
{% if history %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table data-table-sm">
|
<table class="data-table data-table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Type</th>
|
<th>Type</th><th>Target</th><th>Detail</th><th>Reason</th>
|
||||||
<th>Target</th>
|
<th>By</th><th>Created</th><th>Expires</th><th>Active</th>
|
||||||
<th>Detail</th>
|
|
||||||
<th>Reason</th>
|
|
||||||
<th>By</th>
|
|
||||||
<th>Created</th>
|
|
||||||
<th>Expires</th>
|
|
||||||
<th>Active</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -147,9 +129,7 @@
|
|||||||
<td>{{ s.reason }}</td>
|
<td>{{ s.reason }}</td>
|
||||||
<td>{{ s.suppressed_by }}</td>
|
<td>{{ s.suppressed_by }}</td>
|
||||||
<td class="ts-cell">{{ s.created_at }}</td>
|
<td class="ts-cell">{{ s.created_at }}</td>
|
||||||
<td class="ts-cell">
|
<td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td>
|
||||||
{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
{% if s.active %}
|
{% if s.active %}
|
||||||
<span class="badge badge-ok">Yes</span>
|
<span class="badge badge-ok">Yes</span>
|
||||||
@@ -167,22 +147,22 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── Available targets reference ───────────────────────────────────── -->
|
<!-- ── Available targets reference ───────────────────────────────── -->
|
||||||
<section class="section">
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
<h2 class="section-title">Available Targets</h2>
|
<h2 class="section-title">Available Targets</h2>
|
||||||
|
</div>
|
||||||
<div class="targets-grid">
|
<div class="targets-grid">
|
||||||
{% for name, host in snapshot.hosts.items() %}
|
{% for name, host in snapshot.hosts.items() %}
|
||||||
<div class="target-card">
|
<div class="target-card">
|
||||||
<div class="target-name">{{ name }}</div>
|
<div class="target-name">{{ name }}</div>
|
||||||
<div class="target-type">Proxmox Host</div>
|
<div class="target-type">{{ 'Proxmox Host (prometheus)' if host.source == 'prometheus' else 'Ping-only host' }}</div>
|
||||||
{% if host.interfaces %}
|
{% if host.interfaces %}
|
||||||
<div class="target-ifaces">
|
<div class="target-ifaces">
|
||||||
{% for iface in host.interfaces.keys() | sort %}
|
{% for iface in host.interfaces.keys() | sort %}
|
||||||
<code class="iface-chip">{{ iface }}</code>
|
<code class="iface-chip">{{ iface }}</code>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
|
||||||
<div class="target-type">ping-only</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -195,11 +175,9 @@
|
|||||||
<script>
|
<script>
|
||||||
function onTypeChange() {
|
function onTypeChange() {
|
||||||
const t = document.getElementById('s-type').value;
|
const t = document.getElementById('s-type').value;
|
||||||
const nameGrp = document.getElementById('name-group');
|
document.getElementById('name-group').style.display = (t==='all') ? 'none' : '';
|
||||||
const detailGrp = document.getElementById('detail-group');
|
document.getElementById('detail-group').style.display = (t==='interface') ? '' : 'none';
|
||||||
nameGrp.style.display = (t === 'all') ? 'none' : '';
|
document.getElementById('s-name').required = (t!=='all');
|
||||||
detailGrp.style.display = (t === 'interface') ? '' : 'none';
|
|
||||||
document.getElementById('s-name').required = (t !== 'all');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDur(mins) {
|
function setDur(mins) {
|
||||||
@@ -208,10 +186,10 @@
|
|||||||
event.target.classList.add('active');
|
event.target.classList.add('active');
|
||||||
const hint = document.getElementById('s-dur-hint');
|
const hint = document.getElementById('s-dur-hint');
|
||||||
if (mins) {
|
if (mins) {
|
||||||
const h = Math.floor(mins / 60), m = mins % 60;
|
const h = Math.floor(mins/60), m = mins%60;
|
||||||
hint.textContent = `Suppression expires in ${h ? h+'h ' : ''}${m ? m+'m' : ''}.`;
|
hint.textContent = `Expires in ${h?h+'h ':''} ${m?m+'m':''}`.trim()+'.';
|
||||||
} else {
|
} else {
|
||||||
hint.textContent = 'This suppression will persist until manually removed.';
|
hint.textContent = 'Persists until manually removed.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,13 +213,13 @@
|
|||||||
showToast('Suppression applied', 'success');
|
showToast('Suppression applied', 'success');
|
||||||
setTimeout(() => location.reload(), 800);
|
setTimeout(() => location.reload(), 800);
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Error applying suppression', 'error');
|
showToast(data.error || 'Error', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeSuppression(id) {
|
async function removeSuppression(id) {
|
||||||
if (!confirm('Remove this suppression?')) return;
|
if (!confirm('Remove this suppression?')) return;
|
||||||
const resp = await fetch(`/api/suppressions/${id}`, { method: 'DELETE' });
|
const resp = await fetch(`/api/suppressions/${id}`, {method:'DELETE'});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
document.getElementById(`sup-row-${id}`)?.remove();
|
document.getElementById(`sup-row-${id}`)?.remove();
|
||||||
|
|||||||
Reference in New Issue
Block a user