2026-03-01 23:03:18 -05:00
|
|
|
|
{% 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: {{ last_check }}</span>
|
|
|
|
|
|
<button class="btn-refresh" onclick="refreshAll()">↻ Refresh</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- ── Network topology + host grid ──────────────────────────────────── -->
|
|
|
|
|
|
<section class="section">
|
|
|
|
|
|
<h2 class="section-title">Network Hosts</h2>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Simple topology diagram -->
|
|
|
|
|
|
<div class="topology" id="topology-diagram">
|
|
|
|
|
|
<div class="topo-row topo-row-internet">
|
|
|
|
|
|
<div class="topo-node topo-internet">🌐 Internet</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 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>
|
2025-01-04 01:27:49 -05:00
|
|
|
|
</div>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
</div>
|
2025-01-04 01:27:49 -05:00
|
|
|
|
|
2026-03-01 23:03:18 -05:00
|
|
|
|
{% 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>
|
2025-02-07 20:58:23 -05:00
|
|
|
|
</div>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
{% endfor %}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{% else %}
|
|
|
|
|
|
<div class="host-ping-note">Monitored via ping only</div>
|
|
|
|
|
|
{% endif %}
|
|
|
|
|
|
|
|
|
|
|
|
<div class="host-actions">
|
|
|
|
|
|
<button class="btn-sm btn-suppress"
|
|
|
|
|
|
onclick="openSuppressModal('host', '{{ name }}', '')"
|
|
|
|
|
|
title="Suppress alerts for this host">
|
|
|
|
|
|
🔕 Suppress Host
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2025-01-04 00:33:04 -05:00
|
|
|
|
</div>
|
2026-03-01 23:03:18 -05:00
|
|
|
|
{% else %}
|
|
|
|
|
|
<p class="empty-state">No host data yet – monitor is initializing.</p>
|
|
|
|
|
|
{% endfor %}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- ── UniFi devices ──────────────────────────────────────────────────── -->
|
|
|
|
|
|
{% if snapshot.unifi %}
|
|
|
|
|
|
<section class="section">
|
|
|
|
|
|
<h2 class="section-title">UniFi Devices</h2>
|
|
|
|
|
|
<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"
|
|
|
|
|
|
onclick="openSuppressModal('unifi_device', '{{ d.name }}', '')">
|
|
|
|
|
|
🔕 Suppress
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{% endif %}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{% endfor %}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
{% endif %}
|
|
|
|
|
|
|
|
|
|
|
|
<!-- ── Active alerts ─────────────────────────────────────────────────── -->
|
|
|
|
|
|
<section class="section">
|
|
|
|
|
|
<h2 class="section-title">
|
|
|
|
|
|
Active Alerts
|
|
|
|
|
|
{% if summary.critical or summary.warning %}
|
|
|
|
|
|
<span class="section-badge badge-critical">{{ (summary.critical or 0) + (summary.warning or 0) }} open</span>
|
|
|
|
|
|
{% endif %}
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<div class="table-wrap" id="events-table-wrap">
|
|
|
|
|
|
{% if events %}
|
|
|
|
|
|
<table class="data-table" id="events-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Severity</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"
|
|
|
|
|
|
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">
|
|
|
|
|
|
🔕
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{% endif %}
|
|
|
|
|
|
{% else %}
|
|
|
|
|
|
<tr class="empty-row">
|
|
|
|
|
|
<td colspan="9" class="empty-state">No active alerts ✔</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{% endfor %}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
{% 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">
|
|
|
|
|
|
<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">Everything (global maintenance)</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group" id="sup-name-group">
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<label>Interface Name <span class="form-hint">(for interface type)</span></label>
|
|
|
|
|
|
<input type="text" id="sup-detail" name="target_detail" placeholder="e.g. enp35s0">
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<label>Duration</label>
|
|
|
|
|
|
<div class="duration-pills">
|
|
|
|
|
|
<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(240)">4 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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<div class="modal-actions">
|
|
|
|
|
|
<button type="button" class="btn btn-secondary" onclick="closeSuppressModal()">Cancel</button>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Apply Suppression</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{% endblock %}
|
|
|
|
|
|
|
|
|
|
|
|
{% block scripts %}
|
|
|
|
|
|
<script>
|
|
|
|
|
|
// Auto-refresh every 30 seconds
|
|
|
|
|
|
setInterval(refreshAll, 30000);
|
|
|
|
|
|
</script>
|
|
|
|
|
|
{% endblock %}
|