Files
gandalf/templates/index.html
Jared Vititoe 0278dad502 feat: inspector page, link debug enhancements, security hardening
- Add /inspector page: visual model-accurate switch chassis diagrams
  (USF5P, USL8A, US24PRO, USPPDUP, USMINI), clickable port blocks
  with color coding (green=up, amber=PoE, cyan=uplink, grey=down),
  detail panel with stats/PoE/LLDP, LLDP-based path debug side-by-side

- Link Debug: port number badges (#N), LLDP neighbor line, PoE class/max,
  collapsible host/switch panels with sessionStorage persistence

- monitor.py: collect LLDP neighbor map + PoE class/max/mode per switch
  port; PulseClient uses requests.Session() for HTTP keep-alive; add
  shlex.quote() around interface names (defense-in-depth)

- Security: suppress buttons use data-* attrs + delegated click handler
  instead of inline onclick with Jinja2 variable interpolation; remove
  | safe filter from user-controlled fields in suppressions.html;
  setDuration() takes explicit el param instead of implicit event global

- db.py: thread-local connection reuse with ping(reconnect=True) to
  avoid a new TCP handshake per query

- .gitignore: add config.json (contains credentials), __pycache__

- README: full rewrite covering architecture, all 4 pages, alert logic,
  config reference, deployment, troubleshooting, security notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:39:48 -05:00

304 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Dashboard GANDALF{% endblock %}
{% block content %}
<!-- ── Status bar ──────────────────────────────────────────────────── -->
<div class="status-bar">
<div class="status-chips">
{% if summary.critical %}
<span class="chip chip-critical">● {{ summary.critical }} CRITICAL</span>
{% endif %}
{% if summary.warning %}
<span class="chip chip-warning">● {{ summary.warning }} WARNING</span>
{% endif %}
{% if not summary.critical and not summary.warning %}
<span class="chip chip-ok">✔ ALL SYSTEMS NOMINAL</span>
{% endif %}
</div>
<div class="status-meta">
<span class="last-check" id="last-check">{{ last_check }}</span>
<button class="btn-refresh" onclick="refreshAll()">↻ REFRESH</button>
</div>
</div>
<!-- ── Network topology + host grid ───────────────────────────────── -->
<section class="section">
<div class="section-header">
<h2 class="section-title">Network Hosts</h2>
</div>
<div class="topology" id="topology-diagram">
<div class="topo-row topo-row-internet">
<div class="topo-node topo-internet">
<span class="topo-icon"></span>
<span class="topo-label">Internet</span>
</div>
</div>
<div class="topo-connectors single">
<div class="topo-line"></div>
</div>
<div class="topo-row">
<div class="topo-node topo-unifi" id="topo-gateway">
<span class="topo-icon"></span>
<span class="topo-label">UDM-Pro</span>
<span class="topo-status-dot" data-topo-target="gateway"></span>
</div>
</div>
<div class="topo-connectors single">
<div class="topo-line topo-line-labeled" data-link-label="10G DAC"></div>
</div>
<div class="topo-row">
<div class="topo-node topo-switch" id="topo-switch-agg">
<span class="topo-icon"></span>
<span class="topo-label">Agg Switch</span>
<span class="topo-status-dot" data-topo-target="switch-agg"></span>
</div>
</div>
<div class="topo-connectors single">
<div class="topo-line topo-line-labeled" data-link-label="10G DAC"></div>
</div>
<div class="topo-row">
<div class="topo-node topo-switch" id="topo-switch-poe">
<span class="topo-icon"></span>
<span class="topo-label">PoE Switch</span>
<span class="topo-status-dot" data-topo-target="switch-poe"></span>
</div>
</div>
<div class="topo-connectors wide">
{% for name in snapshot.hosts %}
<div class="topo-line"></div>
{% endfor %}
</div>
<div class="topo-row topo-hosts-row">
{% for name, host in snapshot.hosts.items() %}
<div class="topo-node topo-host topo-status-{{ host.status }}" data-host="{{ name }}">
<span class="topo-icon"></span>
<span class="topo-label">{{ name }}</span>
<span class="topo-badge topo-badge-{{ host.status }}">{{ host.status }}</span>
</div>
{% endfor %}
</div>
</div>
<!-- Host cards -->
<div class="host-grid" id="host-grid">
{% for name, host in snapshot.hosts.items() %}
{% set suppressed = suppressions | selectattr('target_name', 'equalto', name) | list %}
<div class="host-card host-card-{{ host.status }}" data-host="{{ name }}">
<div class="host-card-header">
<div class="host-name-row">
<span class="host-status-dot dot-{{ host.status }}"></span>
<span class="host-name">{{ name }}</span>
{% if suppressed %}
<span class="badge-suppressed" title="Suppressed">🔕</span>
{% endif %}
</div>
<div class="host-meta">
<span class="host-ip">{{ host.ip }}</span>
<span class="host-source source-{{ host.source }}">{{ host.source }}</span>
</div>
</div>
{% if host.interfaces %}
<div class="iface-list">
{% for iface, state in host.interfaces.items() | sort %}
<div class="iface-row">
<span class="iface-dot dot-{{ state }}"></span>
<span class="iface-name">{{ iface }}</span>
<span class="iface-state state-{{ state }}">{{ state }}</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="host-ping-note">ping-only / no node_exporter</div>
{% endif %}
<div class="host-actions">
<button class="btn-sm btn-suppress"
data-sup-type="host"
data-sup-name="{{ name }}"
data-sup-detail=""
title="Suppress alerts for this host">
🔕 Suppress
</button>
<a href="{{ url_for('links_page') }}#{{ name }}"
class="btn-sm btn-secondary" style="text-decoration:none">
↗ Links
</a>
</div>
</div>
{% else %}
<p class="empty-state">No host data yet monitor is initializing.</p>
{% endfor %}
</div>
</section>
<!-- ── UniFi devices ────────────────────────────────────────────────── -->
{% if snapshot.unifi %}
<section class="section">
<div class="section-header">
<h2 class="section-title">UniFi Devices</h2>
</div>
<div class="table-wrap">
<table class="data-table" id="unifi-table">
<thead>
<tr>
<th>Status</th>
<th>Name</th>
<th>Type</th>
<th>Model</th>
<th>IP</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for d in snapshot.unifi %}
<tr class="{% if not d.connected %}row-critical{% endif %}">
<td>
<span class="dot-{{ 'up' if d.connected else 'down' }}"></span>
{{ 'ONLINE' if d.connected else 'OFFLINE' }}
</td>
<td><strong>{{ d.name }}</strong></td>
<td>{{ d.type }}</td>
<td>{{ d.model }}</td>
<td>{{ d.ip }}</td>
<td>
{% if not d.connected %}
<button class="btn-sm btn-suppress"
data-sup-type="unifi_device"
data-sup-name="{{ d.name }}"
data-sup-detail="">
🔕 Suppress
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
<!-- ── Active alerts ───────────────────────────────────────────────── -->
<section class="section">
<div class="section-header">
<h2 class="section-title">Active Alerts</h2>
{% if summary.critical or summary.warning %}
<span class="section-badge">{{ (summary.critical or 0) + (summary.warning or 0) }}</span>
{% endif %}
</div>
<div id="events-table-wrap">
{% if events %}
<div class="table-wrap">
<table class="data-table" id="events-table">
<thead>
<tr>
<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>
</tr>
</thead>
<tbody>
{% for e in events %}
{% if e.severity != 'info' %}
<tr class="row-{{ e.severity }}">
<td><span class="badge badge-{{ e.severity }}">{{ e.severity }}</span></td>
<td>{{ e.event_type | replace('_', ' ') }}</td>
<td><strong>{{ e.target_name }}</strong></td>
<td>{{ e.target_detail or '' }}</td>
<td class="desc-cell" title="{{ e.description }}">{{ e.description | truncate(60) }}</td>
<td class="ts-cell">{{ e.first_seen }}</td>
<td>{{ e.consecutive_failures }}</td>
<td>
{% if e.ticket_id %}
<a href="http://t.lotusguild.org/ticket/{{ e.ticket_id }}" target="_blank"
class="ticket-link">#{{ e.ticket_id }}</a>
{% else %}{% endif %}
</td>
<td>
<button class="btn-sm btn-suppress"
data-sup-type="{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}"
data-sup-name="{{ e.target_name }}"
data-sup-detail="{{ e.target_detail or '' }}"
title="Suppress">🔕</button>
</td>
</tr>
{% endif %}
{% else %}
<tr><td colspan="9" class="empty-state">No active alerts ✔</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No active alerts ✔</p>
{% endif %}
</div>
</section>
<!-- ── Quick-suppress modal ─────────────────────────────────────────── -->
<div id="suppress-modal" class="modal-overlay" style="display:none">
<div class="modal">
<div class="modal-header">
<h3>Suppress Alert</h3>
<button class="modal-close" onclick="closeSuppressModal()"></button>
</div>
<form id="suppress-form" onsubmit="submitSuppress(event)">
<div class="form-group" style="margin-bottom:10px">
<label>Target Type</label>
<select id="sup-type" name="target_type" onchange="updateSuppressForm()">
<option value="host">Host (all interfaces)</option>
<option value="interface">Specific Interface</option>
<option value="unifi_device">UniFi Device</option>
<option value="all">Global Maintenance</option>
</select>
</div>
<div class="form-group" id="sup-name-group" style="margin-bottom:10px">
<label>Target Name</label>
<input type="text" id="sup-name" name="target_name" placeholder="e.g. large1">
</div>
<div class="form-group" id="sup-detail-group" style="margin-bottom:10px;display:none">
<label>Interface <span class="form-hint">(interface type only)</span></label>
<input type="text" id="sup-detail" name="target_detail" placeholder="e.g. enp35s0">
</div>
<div class="form-group" style="margin-bottom:10px">
<label>Reason <span class="required">*</span></label>
<input type="text" id="sup-reason" name="reason"
placeholder="e.g. Planned switch reboot" required>
</div>
<div class="form-group" style="margin-bottom:0">
<label>Duration</label>
<div class="duration-pills">
<button type="button" class="pill" onclick="setDuration(30, this)">30 min</button>
<button type="button" class="pill" onclick="setDuration(60, this)">1 hr</button>
<button type="button" class="pill" onclick="setDuration(240, this)">4 hr</button>
<button type="button" class="pill" onclick="setDuration(480, this)">8 hr</button>
<button type="button" class="pill pill-manual active" onclick="setDuration(null, this)">Manual ∞</button>
</div>
<input type="hidden" id="sup-expires" name="expires_minutes" value="">
<div class="form-hint" id="duration-hint">Persists until manually removed.</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeSuppressModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Apply</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
setInterval(refreshAll, 30000);
</script>
{% endblock %}