Files
gandalf/templates/index.html
T
jared e8de40250a
Lint / Python (flake8) (push) Failing after 45s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Failing after 1m22s
Test / Python Tests (pytest) (push) Successful in 51s
Lint / Notify on failure (push) Successful in 2s
Lint / Deploy (push) Has been skipped
Restructure app to use LotusGuild Terminal Design System v1.2
Replace custom phosphor-green terminal aesthetic with the lt-* component
system from base.css/base.js. All templates now inherit the LotusGuild
multi-accent Anduril palette via variable aliases in style.css, and use
lt-header, lt-nav, lt-card, lt-table, lt-btn, lt-modal, lt-badge etc.
Custom components (topology, inspector chassis, link debug, SFP panels)
are preserved with color values updated to base.css palette variables.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 21:01:20 -04:00

480 lines
21 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="lt-btn lt-btn-ghost lt-btn-sm" onclick="refreshAll()">↻ REFRESH</button>
</div>
</div>
<!-- ── Network topology + host grid ───────────────────────────────── -->
<section class="g-section">
<div class="g-section-header">
<h2 class="g-section-title">Network Hosts</h2>
</div>
<div class="topology" id="topology-diagram">
<div class="topo-v2">
{%- set topo_h = snapshot.hosts if snapshot.hosts else {} -%}
<!-- ══════════════════════════════════════════════════════════════
TIER 1: Internet (WAN edge)
══════════════════════════════════════════════════════════ -->
<div class="topo-tier">
<div class="topo-v2-node topo-v2-internet">
<span class="topo-v2-icon"></span>
<span class="topo-v2-label">INTERNET</span>
<span class="topo-v2-sub">WAN uplink</span>
</div>
</div>
<!-- WAN wire: cyan → green gradient, labeled -->
<div class="topo-vc">
<div class="topo-vc-wire" style="background:linear-gradient(to bottom,var(--cyan),var(--cyan)); opacity:.55;"></div>
<span class="topo-vc-label">WAN · 10G SFP+</span>
</div>
<!-- ══════════════════════════════════════════════════════════════
TIER 2: Router UDM-Pro
══════════════════════════════════════════════════════════ -->
<div class="topo-tier">
<div class="topo-v2-node topo-v2-router">
<span class="topo-v2-icon"></span>
<span class="topo-v2-label">UDM-Pro</span>
<span class="topo-v2-sub">Dream Machine Pro</span>
<span class="topo-v2-sub">RU24</span>
</div>
</div>
<!-- UDM-Pro → USW-Agg (10G SFP+) -->
<div class="topo-vc">
<div class="topo-vc-wire" style="background:var(--amber);opacity:.6;"></div>
<span class="topo-vc-label">10G SFP+</span>
</div>
<!-- ══════════════════════════════════════════════════════════════
TIER 3: USW-Aggregation
══════════════════════════════════════════════════════════ -->
<div class="topo-tier">
<div class="topo-v2-node topo-v2-switch" id="topo-switch-agg">
<span class="topo-v2-icon"></span>
<span class="topo-v2-label">USW-Agg</span>
<span class="topo-v2-sub">Aggregation · RU22</span>
<span class="topo-v2-sub">8 × 10G SFP+</span>
<span class="topo-v2-vlan">VLAN90 · 10.10.90.x/24</span>
</div>
</div>
<!-- USW-Agg → Pro 24 PoE (10G trunk) -->
<div class="topo-vc">
<div class="topo-vc-wire" style="background:var(--amber);opacity:.6;"></div>
<span class="topo-vc-label">10G trunk</span>
</div>
<!-- ══════════════════════════════════════════════════════════════
TIER 4: Pro 24 PoE
══════════════════════════════════════════════════════════ -->
<div class="topo-tier">
<div class="topo-v2-node topo-v2-switch" id="topo-switch-poe">
<span class="topo-v2-icon"></span>
<span class="topo-v2-label">Pro 24 PoE</span>
<span class="topo-v2-sub">24-Port · RU23</span>
<span class="topo-v2-sub">24 × 1G PoE</span>
<span class="topo-v2-vlan">DHCP · mgmt</span>
</div>
</div>
<!-- Pro 24 PoE → host bus section -->
<div class="topo-vc">
<div class="topo-vc-wire" style="background:var(--border-color);opacity:.5;"></div>
</div>
<!-- ══════════════════════════════════════════════════════════════
TIER 4 connecting bus two rails (10G green + 1G amber dashed)
showing dual-homing for all 6 servers
══════════════════════════════════════════════════════════ -->
<div class="topo-bus-section" style="max-width:860px;">
<!-- 10G storage bus (Agg → VLAN90) -->
<div class="topo-bus-10g">
<span class="topo-bus-10g-label">← USW-Agg · 10G SFP+ · VLAN90 →</span>
<div class="topo-bus-10g-line"></div>
</div>
<!-- 1G management bus (PoE → DHCP) -->
<div class="topo-bus-1g">
<span class="topo-bus-1g-label">← Pro 24 PoE · 1G · DHCP mgmt →</span>
<div class="topo-bus-1g-line"></div>
</div>
<!-- ── Host nodes with drop wires ── -->
<div class="topo-v2-hosts">
{%- set all_defs = [
('compute-storage-gpu-01', 'csg-01', 'RU412', 'Ceph · VLAN90', False),
('compute-storage-01', 'cs-01', 'RU1417', 'Ceph · VLAN90', False),
('storage-01', 'sto-01', 'rack', 'Ceph · VLAN90', False),
('monitor-01', 'mon-01', 'ZimaBoard', 'mgmt', False),
('monitor-02', 'mon-02', 'ZimaBoard', 'mgmt', False),
('large1', 'large1', 'off-rack', 'table', True),
] -%}
{%- for hname, hlabel, hsub, hvlan, off_rack in all_defs -%}
{%- set st = topo_h[hname].status if hname in topo_h else 'unknown' -%}
<div class="topo-v2-host-wrap">
<!-- dual-homing wires: 10G solid green + 1G dashed amber -->
<div class="topo-v2-host-wires">
<div class="topo-v2-wire-10g" title="10G SFP+ → USW-Agg"></div>
<div class="topo-v2-wire-1g" title="1G → Pro 24 PoE"></div>
</div>
<!-- host box -->
<div class="topo-v2-node topo-v2-host topo-host topo-v2-status-{{ st }}{{ ' topo-v2-offrack' if off_rack else '' }}"
data-host="{{ hname }}" style="min-width:80px; max-width:96px;">
<span class="topo-v2-icon"></span>
<span class="topo-v2-label">{{ hlabel }}</span>
<span class="topo-v2-sub">{{ hsub }}</span>
<span class="topo-v2-vlan">{{ hvlan }}</span>
<span class="topo-badge topo-badge-{{ st }}">{{ st if st != 'unknown' else '' }}</span>
</div>
</div>
{%- endfor -%}
</div>
</div><!-- /topo-bus-section -->
<!-- ── Legend ── -->
<div class="topo-legend">
<div class="topo-legend-item"><span class="topo-legend-line-wan"></span> WAN / uplink</div>
<div class="topo-legend-item"><span class="topo-legend-line-10g"></span> 10G SFP+ (Ceph / VLAN90)</div>
<div class="topo-legend-item"><span class="topo-legend-line-1g"></span> 1G DHCP (mgmt)</div>
<div class="topo-legend-item" style="border:1px dashed var(--border-color); padding:1px 5px; font-size:.56em; color:var(--text-muted);">dashed border = off-rack</div>
</div>
</div><!-- /topo-v2 -->
</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="lt-btn lt-btn-ghost lt-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="lt-btn lt-btn-secondary lt-btn-sm" 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="g-section">
<div class="g-section-header">
<h2 class="g-section-title">UniFi Devices</h2>
</div>
<div class="lt-table-wrap">
<table class="lt-table" id="unifi-table">
<caption class="lt-sr-only">UniFi network devices</caption>
<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="lt-btn lt-btn-ghost lt-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="g-section">
<div class="g-section-header">
<h2 class="g-section-title">Active Alerts</h2>
{% if summary.critical or summary.warning %}
<span class="g-section-badge">{{ (summary.critical or 0) + (summary.warning or 0) }}</span>
{% endif %}
</div>
<div id="events-table-wrap">
{% if events %}
{% if total_active is defined and total_active > events|length %}
<div class="pagination-notice">Showing {{ events|length }} of {{ total_active }} active alerts &mdash; <a href="/api/events?limit=1000">view all via API</a></div>
{% endif %}
<div class="lt-table-wrap">
<table class="lt-table" id="events-table">
<caption class="lt-sr-only">Active network alerts</caption>
<thead>
<tr>
<th>Sev</th>
<th>Type</th>
<th>Target</th>
<th>Detail</th>
<th>Description</th>
<th>First Seen</th>
<th>Last 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="lt-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 }}">{{ e.description | truncate(60) }}</td>
<td class="ts-cell" title="{{ e.first_seen }}">
<span class="event-age" data-ts="{{ e.first_seen }}">{{ e.first_seen }}</span>
</td>
<td class="ts-cell" title="{{ e.last_seen }}">
<span class="event-age" data-ts="{{ e.last_seen }}">{{ e.last_seen }}</span>
</td>
<td>{{ e.consecutive_failures }}</td>
<td>
{% if e.ticket_id %}
<a href="{{ config.ticket_api.web_url }}{{ e.ticket_id }}" target="_blank"
class="ticket-link">#{{ e.ticket_id }}</a>
{% else %}{% endif %}
</td>
<td>
<button class="lt-btn lt-btn-ghost lt-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="10" class="empty-state">No active alerts ✔</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No active alerts ✔</p>
{% endif %}
</div>
</section>
<!-- ── Recently Resolved (last 24h) ───────────────────────────────── -->
{% if recent_resolved %}
<section class="g-section">
<div class="g-section-header">
<h2 class="g-section-title">Recently Resolved</h2>
<span class="g-section-badge g-section-badge-resolved">{{ recent_resolved | length }} in last 24h</span>
</div>
<div class="lt-table-wrap">
<table class="lt-table">
<caption class="lt-sr-only">Recently resolved alerts</caption>
<thead>
<tr>
<th>Sev</th>
<th>Type</th>
<th>Target</th>
<th>Detail</th>
<th>Resolved</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{% for e in recent_resolved %}
<tr class="row-resolved">
<td><span class="lt-badge badge-resolved">{{ 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="ts-cell">
<span class="event-age" data-ts="{{ e.resolved_at }}">{{ e.resolved_at }}</span>
</td>
<td class="ts-cell event-duration" data-first="{{ e.first_seen }}" data-resolved="{{ e.resolved_at }}"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
<!-- ── Quick-suppress modal ─────────────────────────────────────────── -->
<div id="suppress-modal" class="lt-modal-backdrop" style="display:none"
role="dialog" aria-modal="true" aria-labelledby="suppress-modal-title">
<div class="lt-modal">
<div class="lt-modal-header">
<h3 class="lt-modal-title" id="suppress-modal-title">Suppress Alert</h3>
<button type="button" class="lt-modal-close" onclick="closeSuppressModal()" aria-label="Close"></button>
</div>
<form id="suppress-form" onsubmit="submitSuppress(event)">
<div class="lt-modal-body">
<div class="lt-form-group" style="margin-bottom:12px">
<label class="lt-label" for="sup-type">Target Type</label>
<select class="lt-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="lt-form-group" id="sup-name-group" style="margin-bottom:12px">
<label class="lt-label" for="sup-name">Target Name</label>
<input type="text" class="lt-input" id="sup-name" name="target_name" placeholder="e.g. large1">
</div>
<div class="lt-form-group" id="sup-detail-group" style="margin-bottom:12px;display:none">
<label class="lt-label" for="sup-detail">Interface <span class="lt-field-hint">(interface type only)</span></label>
<input type="text" class="lt-input" id="sup-detail" name="target_detail" placeholder="e.g. enp35s0">
</div>
<div class="lt-form-group" style="margin-bottom:12px">
<label class="lt-label" for="sup-reason">Reason <span class="required">*</span></label>
<input type="text" class="lt-input" id="sup-reason" name="reason"
placeholder="e.g. Planned switch reboot" required>
</div>
<div class="lt-form-group" style="margin-bottom:0">
<label class="lt-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="lt-field-hint" id="duration-hint">Persists until manually removed.</div>
</div>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn lt-btn-secondary" onclick="closeSuppressModal()">Cancel</button>
<button type="submit" class="lt-btn lt-btn-primary">Apply</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
setInterval(refreshAll, 30000);
// ── Relative time display for event age cells ──────────────────
function fmtRelTime(tsStr) {
if (!tsStr) return '';
const d = new Date(tsStr.replace(' UTC', 'Z').replace(' ', 'T'));
if (isNaN(d)) return tsStr;
const secs = Math.floor((Date.now() - d) / 1000);
if (secs < 60) return `${secs}s ago`;
if (secs < 3600) return `${Math.floor(secs/60)}m ago`;
if (secs < 86400) return `${Math.floor(secs/3600)}h ago`;
return `${Math.floor(secs/86400)}d ago`;
}
function updateEventAges() {
document.querySelectorAll('.event-age[data-ts]').forEach(el => {
el.textContent = fmtRelTime(el.dataset.ts);
});
}
updateEventAges();
setInterval(updateEventAges, 60000);
// ── Event duration (resolved_at - first_seen) ──────────────────
function fmtDuration(firstTs, resolvedTs) {
if (!firstTs || !resolvedTs) return '';
const parse = s => new Date(s.replace(' UTC', 'Z').replace(' ', 'T'));
const secs = Math.floor((parse(resolvedTs) - parse(firstTs)) / 1000);
if (secs < 0) return '';
if (secs < 60) return `${secs}s`;
if (secs < 3600) return `${Math.floor(secs/60)}m`;
if (secs < 86400) return `${Math.floor(secs/3600)}h ${Math.floor((secs%3600)/60)}m`;
return `${Math.floor(secs/86400)}d`;
}
document.querySelectorAll('.event-duration[data-first][data-resolved]').forEach(el => {
el.textContent = fmtDuration(el.dataset.first, el.dataset.resolved);
});
</script>
{% endblock %}