Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 906869f425 | |||
| c027b5422a | |||
| d3e8191f26 | |||
| ed19838a4e |
@@ -160,6 +160,16 @@ def index():
|
||||
last_check = db.get_state('last_check', 'Never')
|
||||
snapshot = json.loads(snapshot_raw) if snapshot_raw else {}
|
||||
suppressions = db.get_active_suppressions()
|
||||
for ev in events:
|
||||
sup_type = (
|
||||
'unifi_device' if ev.get('event_type') == 'unifi_device_offline'
|
||||
else 'interface' if ev.get('event_type') == 'interface_down'
|
||||
else 'host'
|
||||
)
|
||||
ev['is_suppressed'] = db.check_suppressed(
|
||||
suppressions, sup_type,
|
||||
ev.get('target_name', ''), ev.get('target_detail', '') or '',
|
||||
)
|
||||
recent_resolved = db.get_recent_resolved(hours=24, limit=10)
|
||||
return render_template(
|
||||
'index.html',
|
||||
@@ -214,6 +224,17 @@ def suppressions_page():
|
||||
@require_auth
|
||||
def api_status():
|
||||
active = db.get_active_events(limit=_PAGE_LIMIT)
|
||||
suppressions = db.get_active_suppressions()
|
||||
for ev in active:
|
||||
sup_type = (
|
||||
'unifi_device' if ev.get('event_type') == 'unifi_device_offline'
|
||||
else 'interface' if ev.get('event_type') == 'interface_down'
|
||||
else 'host'
|
||||
)
|
||||
ev['is_suppressed'] = db.check_suppressed(
|
||||
suppressions, sup_type,
|
||||
ev.get('target_name', ''), ev.get('target_detail', '') or '',
|
||||
)
|
||||
last_check = db.get_state('last_check', 'Never')
|
||||
return jsonify({
|
||||
'summary': db.get_status_summary(),
|
||||
|
||||
+9
-2
@@ -222,9 +222,12 @@ function updateEventsTable(events, totalActive) {
|
||||
? `<a href="${lt.escHtml(ticketBase)}${lt.escHtml(String(e.ticket_id))}" target="_blank"
|
||||
class="ticket-link">#${e.ticket_id}</a>`
|
||||
: '–';
|
||||
const supBadge = e.is_suppressed
|
||||
? `<span class="lt-badge badge-suppressed" title="Alert suppressed">🔕 sup</span>`
|
||||
: '';
|
||||
return `
|
||||
<tr class="row-${e.severity}">
|
||||
<td><span class="lt-badge badge-${e.severity}">${e.severity}</span></td>
|
||||
<tr class="row-${e.severity}${e.is_suppressed ? ' row-suppressed' : ''}">
|
||||
<td><span class="lt-badge badge-${e.severity}">${e.severity}</span>${supBadge}</td>
|
||||
<td>${lt.escHtml(e.event_type.replace(/_/g,' '))}</td>
|
||||
<td><strong>${lt.escHtml(e.target_name)}</strong></td>
|
||||
<td>${lt.escHtml(e.target_detail || '–')}</td>
|
||||
@@ -334,6 +337,10 @@ async function submitSuppress(e) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Suppress form – wired here so the modal works from any page ──────
|
||||
document.getElementById('suppress-form')?.addEventListener('submit', submitSuppress);
|
||||
document.getElementById('sup-type')?.addEventListener('change', updateSuppressForm);
|
||||
|
||||
// ── Global click delegation ───────────────────────────────────────────
|
||||
document.addEventListener('click', e => {
|
||||
// Refresh button
|
||||
|
||||
+49
-8
@@ -96,6 +96,45 @@
|
||||
content: '…';
|
||||
}
|
||||
|
||||
/* ── Secondary button – dark-mode definition ─────────────────────────
|
||||
base.css only defines .lt-btn-secondary in its light-theme block,
|
||||
so dark mode falls back to the default cyan primary appearance.
|
||||
This restores a visually distinct secondary look in dark mode. */
|
||||
.lt-btn-secondary {
|
||||
background: var(--cyan-dim);
|
||||
border-color: rgba(0,212,255,.28);
|
||||
color: var(--cyan);
|
||||
}
|
||||
.lt-btn-secondary:hover {
|
||||
background: rgba(0,212,255,.18);
|
||||
border-color: rgba(0,212,255,.5);
|
||||
}
|
||||
|
||||
/* ── ⌘K hint button in header ────────────────────────────────────── */
|
||||
.lt-cmd-hint-btn {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.55;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 0.2rem 0.45rem;
|
||||
}
|
||||
|
||||
/* ── Topology section collapse toggle ────────────────────────────── */
|
||||
.topo-collapse-btn {
|
||||
margin-left: auto;
|
||||
font-size: .7em;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
letter-spacing: .04em;
|
||||
transition: border-color .15s, color .15s;
|
||||
}
|
||||
.topo-collapse-btn:hover { border-color: var(--amber); color: var(--amber); }
|
||||
.topo-collapsible { overflow: hidden; transition: max-height .25s ease; }
|
||||
.topo-collapsible.is-collapsed { display: none; }
|
||||
|
||||
/* ── Animations used by custom components ─────────────────────────── */
|
||||
@keyframes pulse-red {
|
||||
0%,100% { box-shadow: 0 0 0 0 rgba(255,45,85,.5); }
|
||||
@@ -145,13 +184,6 @@
|
||||
.events-filter-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.events-filter-bar .lt-input-sm { width: 220px; }
|
||||
.sev-pills { display: flex; gap: 4px; }
|
||||
.g-page-header { margin-bottom: 20px; }
|
||||
.g-page-title {
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
color: var(--text-accent);
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
.g-page-sub { font-size: .78em; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
/* ── Badge severity color variants (used with lt-badge) ───────────── */
|
||||
@@ -171,6 +203,8 @@
|
||||
.lt-table tr.row-warning td { background: rgba(255,107,0,.04); }
|
||||
.lt-table tr.row-warning td:first-child { border-left: 2px solid var(--orange); }
|
||||
.lt-table tr.row-resolved td { opacity: .65; }
|
||||
.lt-table tr.row-suppressed td { opacity: .6; }
|
||||
.lt-table tr.row-suppressed td:first-child{ border-left-color: var(--text-muted) !important; }
|
||||
|
||||
/* ── Table size modifier ─────────────────────────────────────────── */
|
||||
.lt-table-sm th,
|
||||
@@ -180,7 +214,6 @@
|
||||
.ts-cell { color: var(--text-muted); font-size: .75em; white-space: nowrap; }
|
||||
.desc-cell { max-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ticket-link{ color: var(--amber); text-shadow: var(--glow-amber); font-weight: bold; }
|
||||
.empty-state { padding: 28px; text-align: center; color: var(--text-muted); font-size: .82em; }
|
||||
.pagination-notice { font-size: .8em; color: var(--text-muted); padding: 6px 0 8px; }
|
||||
.pagination-notice a { color: var(--amber); }
|
||||
|
||||
@@ -377,6 +410,9 @@
|
||||
background: linear-gradient(to bottom, var(--cyan), var(--green));
|
||||
opacity: .7;
|
||||
}
|
||||
.topo-vc-wire--wan { background: linear-gradient(to bottom, var(--cyan), rgba(0,212,255,.3)); opacity: .7; }
|
||||
.topo-vc-wire--10g { background: var(--amber); opacity: .6; }
|
||||
.topo-vc-wire--mgmt { background: var(--border-color); opacity: .5; }
|
||||
/* Blurred copy of the wire for a soft glow halo */
|
||||
.topo-vc-wire::before {
|
||||
content: '';
|
||||
@@ -434,6 +470,7 @@
|
||||
.topo-v2-sub { font-size: .58em; color: var(--text-muted); letter-spacing: .02em; }
|
||||
.topo-v2-vlan { font-size: .54em; color: var(--cyan); opacity: .75; }
|
||||
|
||||
.topo-v2-host--bus { min-width: 80px; max-width: 96px; }
|
||||
.topo-v2-internet { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); box-shadow: 0 0 12px rgba(0,212,255,.12); }
|
||||
.topo-v2-router { border-color: var(--cyan); color: var(--cyan); text-shadow: var(--glow-cyan); box-shadow: 0 0 12px rgba(0,212,255,.14); }
|
||||
.topo-v2-switch { border-color: var(--amber); color: var(--amber); text-shadow: var(--glow-amber); box-shadow: 0 0 12px rgba(255,179,0,.12); }
|
||||
@@ -563,6 +600,10 @@
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font);
|
||||
}
|
||||
.topo-legend-item--offrack {
|
||||
border: 1px dashed var(--border-color);
|
||||
padding: 1px 5px;
|
||||
}
|
||||
.topo-legend-line-10g { width: 24px; height: 2px; background: var(--green); display: inline-block; box-shadow: 0 0 4px rgba(0,255,136,.5); }
|
||||
.topo-legend-line-1g { width: 24px; height: 0; border-top: 2px dashed var(--amber); display: inline-block; }
|
||||
.topo-legend-line-wan { width: 24px; height: 2px; background: linear-gradient(to right, var(--cyan), var(--green)); display: inline-block; }
|
||||
|
||||
+59
-34
@@ -73,7 +73,6 @@
|
||||
<a href="{{ url_for('index') }}"
|
||||
class="lt-brand-title lt-glitch"
|
||||
data-text="GANDALF"
|
||||
style="text-decoration:none"
|
||||
aria-label="GANDALF home">GANDALF</a>
|
||||
<span class="lt-brand-subtitle">Network Monitor // LotusGuild</span>
|
||||
</div>
|
||||
@@ -96,28 +95,6 @@
|
||||
Inspector
|
||||
</a>
|
||||
{% if user.groups and 'admin' in user.groups %}
|
||||
<div class="lt-nav-dropdown" data-action="toggle-nav-dropdown">
|
||||
<a href="#"
|
||||
class="lt-nav-link{% if request.endpoint == 'suppressions_page' %} active{% endif %}"
|
||||
role="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-controls="lt-admin-dropdown-menu">
|
||||
Admin ▾
|
||||
</a>
|
||||
<ul class="lt-nav-dropdown-menu"
|
||||
id="lt-admin-dropdown-menu"
|
||||
role="menu"
|
||||
aria-label="Admin menu">
|
||||
<li role="none">
|
||||
<a href="{{ url_for('suppressions_page') }}" role="menuitem"
|
||||
class="{% if request.endpoint == 'suppressions_page' %}active{% endif %}">
|
||||
Suppressions
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="{{ url_for('suppressions_page') }}"
|
||||
class="lt-nav-link{% if request.endpoint == 'suppressions_page' %} active{% endif %}"
|
||||
{% if request.endpoint == 'suppressions_page' %}aria-current="page"{% endif %}>
|
||||
@@ -166,11 +143,10 @@
|
||||
|
||||
<!-- ⌘K affordance -->
|
||||
<button type="button"
|
||||
class="lt-btn lt-btn-ghost lt-btn-sm"
|
||||
class="lt-btn lt-btn-ghost lt-btn-sm lt-cmd-hint-btn"
|
||||
title="Command palette (Ctrl+K)"
|
||||
aria-label="Open command palette"
|
||||
onclick="if(window.lt&<.cmdPalette)lt.cmdPalette.open()"
|
||||
style="font-size:0.65rem;opacity:0.55;letter-spacing:0.03em;padding:0.2rem 0.45rem">⌕ K</button>
|
||||
onclick="if(window.lt&<.cmdPalette)lt.cmdPalette.open()">⌕ K</button>
|
||||
|
||||
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
|
||||
aria-label="Toggle theme" title="Toggle light/dark mode">☀</button>
|
||||
@@ -221,6 +197,59 @@
|
||||
<span>GANDALF — TDS v1.2</span>
|
||||
</footer>
|
||||
|
||||
<!-- QUICK-SUPPRESS MODAL — available on all pages via [S] shortcut -->
|
||||
<div id="suppress-modal" class="lt-modal-overlay"
|
||||
role="dialog" aria-modal="true" aria-labelledby="suppress-modal-title" aria-hidden="true">
|
||||
<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" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<form id="suppress-form">
|
||||
<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">
|
||||
<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" data-duration="30">30 min</button>
|
||||
<button type="button" class="pill" data-duration="60">1 hr</button>
|
||||
<button type="button" class="pill" data-duration="240">4 hr</button>
|
||||
<button type="button" class="pill" data-duration="480">8 hr</button>
|
||||
<button type="button" class="pill pill-manual active" data-duration="">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" data-modal-close>Cancel</button>
|
||||
<button type="submit" class="lt-btn lt-btn-primary">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KEYBOARD SHORTCUTS MODAL -->
|
||||
<div id="lt-keys-help" class="lt-modal-overlay" aria-hidden="true">
|
||||
<div class="lt-modal" role="dialog" aria-modal="true" aria-labelledby="keys-help-title">
|
||||
@@ -268,15 +297,11 @@
|
||||
</div>
|
||||
<div class="lt-divider" style="margin:1rem 0 0.75rem"></div>
|
||||
<div class="lt-kv-grid">
|
||||
<div class="lt-kv-row">
|
||||
<span class="lt-kv-label">User</span>
|
||||
<span class="lt-kv-value lt-text-cyan">{{ user.name or user.username }}</span>
|
||||
</div>
|
||||
<span class="lt-kv-key">User</span>
|
||||
<span class="lt-kv-val lt-kv-val--cyan">{{ user.name or user.username }}</span>
|
||||
{% if user.groups %}
|
||||
<div class="lt-kv-row">
|
||||
<span class="lt-kv-label">Groups</span>
|
||||
<span class="lt-kv-value">{{ user.groups | join(', ') }}</span>
|
||||
</div>
|
||||
<span class="lt-kv-key">Groups</span>
|
||||
<span class="lt-kv-val">{{ user.groups | join(', ') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+137
-163
@@ -62,11 +62,115 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Active alerts ─────────────────────────────── (above the fold) -->
|
||||
<section class="g-section">
|
||||
<div class="g-section-header">
|
||||
<h2 class="g-section-title">Active Alerts</h2>
|
||||
<span class="g-section-badge" id="alert-count-badge"
|
||||
{% if not summary.critical and not summary.warning %}style="display:none"{% endif %}>{{ (summary.critical or 0) + (summary.warning or 0) }}</span>
|
||||
</div>
|
||||
<div class="lt-toolbar">
|
||||
<div class="lt-toolbar-left">
|
||||
<div class="lt-search">
|
||||
<input type="search" class="lt-input lt-search-input" id="events-search"
|
||||
placeholder="Filter by target, type, description…" autocomplete="off">
|
||||
</div>
|
||||
<div class="sev-pills">
|
||||
<button type="button" class="pill active" data-sev="">All</button>
|
||||
<button type="button" class="pill" data-sev="critical">Critical</button>
|
||||
<button type="button" class="pill" data-sev="warning">Warning</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-frame">
|
||||
<span class="lt-frame-bl">╚</span>
|
||||
<span class="lt-frame-br">╝</span>
|
||||
<div class="lt-section-header">Alert Queue</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 — <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 data-tooltip="Alert severity level" data-tooltip-pos="bottom">Sev</th>
|
||||
<th>Type</th>
|
||||
<th>Target</th>
|
||||
<th>Detail</th>
|
||||
<th>Description</th>
|
||||
<th data-tooltip="When this alert was first raised" data-tooltip-pos="bottom">First Seen</th>
|
||||
<th data-tooltip="Most recent check failure" data-tooltip-pos="bottom">Last Seen</th>
|
||||
<th data-tooltip="Consecutive check failures since first seen" data-tooltip-pos="bottom">Failures</th>
|
||||
<th>Ticket</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in events %}
|
||||
{% if e.severity != 'info' %}
|
||||
<tr class="row-{{ e.severity }}{% if e.is_suppressed %} row-suppressed{% endif %}">
|
||||
<td>
|
||||
<span class="lt-badge badge-{{ e.severity }}">{{ e.severity }}</span>
|
||||
{% if e.is_suppressed %}<span class="lt-badge badge-suppressed" title="Alert suppressed">🔕 sup</span>{% endif %}
|
||||
</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" aria-label="Suppress alert for {{ e.target_name }}">🔕</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<tr><td colspan="10">
|
||||
<div class="lt-empty-state lt-empty-state--sm">
|
||||
<div class="lt-empty-state-icon">✔</div>
|
||||
<div class="lt-empty-state-title">No active alerts</div>
|
||||
</div>
|
||||
</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="lt-empty-state lt-empty-state--sm">
|
||||
<div class="lt-empty-state-icon">✔</div>
|
||||
<div class="lt-empty-state-title">No active alerts</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div><!-- /#events-table-wrap -->
|
||||
</div><!-- /.lt-frame -->
|
||||
</section>
|
||||
|
||||
<!-- ── Network topology + host grid ───────────────────────────────── -->
|
||||
<section class="g-section">
|
||||
<div class="g-section-header">
|
||||
<h2 class="g-section-title">Network Hosts</h2>
|
||||
<button type="button" class="topo-collapse-btn" id="topo-toggle-btn"
|
||||
aria-expanded="true" aria-controls="topo-collapsible-wrap">▴ Collapse</button>
|
||||
</div>
|
||||
<div class="topo-collapsible" id="topo-collapsible-wrap">
|
||||
|
||||
<div class="topology" id="topology-diagram">
|
||||
<div class="topo-v2">
|
||||
@@ -84,9 +188,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WAN wire: cyan → green gradient, labeled -->
|
||||
<!-- WAN wire: cyan → WAN gradient -->
|
||||
<div class="topo-vc">
|
||||
<div class="topo-vc-wire" style="background:linear-gradient(to bottom,var(--cyan),rgba(0,212,255,.3)); opacity:.7;"></div>
|
||||
<div class="topo-vc-wire topo-vc-wire--wan"></div>
|
||||
<span class="topo-vc-label">WAN · 10G SFP+</span>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +208,7 @@
|
||||
|
||||
<!-- UDM-Pro → USW-Agg (10G SFP+) -->
|
||||
<div class="topo-vc">
|
||||
<div class="topo-vc-wire" style="background:var(--amber);opacity:.6;"></div>
|
||||
<div class="topo-vc-wire topo-vc-wire--10g"></div>
|
||||
<span class="topo-vc-label">10G SFP+</span>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +227,7 @@
|
||||
|
||||
<!-- USW-Agg → Pro 24 PoE (10G trunk) -->
|
||||
<div class="topo-vc">
|
||||
<div class="topo-vc-wire" style="background:var(--amber);opacity:.6;"></div>
|
||||
<div class="topo-vc-wire topo-vc-wire--10g"></div>
|
||||
<span class="topo-vc-label">10G trunk</span>
|
||||
</div>
|
||||
|
||||
@@ -142,7 +246,7 @@
|
||||
|
||||
<!-- Pro 24 PoE → host bus section -->
|
||||
<div class="topo-vc">
|
||||
<div class="topo-vc-wire" style="background:var(--border-color);opacity:.5;"></div>
|
||||
<div class="topo-vc-wire topo-vc-wire--mgmt"></div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
@@ -182,8 +286,8 @@
|
||||
<div class="topo-v2-wire-1g" data-host="{{ hname }}" 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;">
|
||||
<div class="topo-v2-node topo-v2-host topo-host topo-v2-status-{{ st }}{{ ' topo-v2-offrack' if off_rack else '' }} topo-v2-host--bus"
|
||||
data-host="{{ hname }}">
|
||||
<span class="topo-v2-icon">▣</span>
|
||||
<span class="topo-v2-label">{{ hlabel }}</span>
|
||||
<span class="topo-v2-sub">{{ hsub }}</span>
|
||||
@@ -201,14 +305,14 @@
|
||||
<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 class="topo-legend-item topo-legend-item--offrack">dashed border = off-rack</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /topo-v2 -->
|
||||
</div>
|
||||
|
||||
<!-- Host cards -->
|
||||
<div class="lt-toolbar" style="margin-bottom:10px" id="host-toolbar">
|
||||
<div class="lt-toolbar" id="host-toolbar">
|
||||
<div class="lt-toolbar-left">
|
||||
<div class="lt-search">
|
||||
<input type="search" class="lt-input lt-search-input" id="host-search"
|
||||
@@ -257,7 +361,7 @@
|
||||
🔕 Suppress
|
||||
</button>
|
||||
<a href="{{ url_for('links_page') }}#{{ name }}"
|
||||
class="lt-btn lt-btn-secondary lt-btn-sm" style="text-decoration:none">
|
||||
class="lt-btn lt-btn-secondary lt-btn-sm">
|
||||
↗ Links
|
||||
</a>
|
||||
</div>
|
||||
@@ -270,6 +374,7 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div><!-- /#topo-collapsible-wrap -->
|
||||
</section>
|
||||
|
||||
<!-- ── UniFi devices ────────────────────────────────────────────────── -->
|
||||
@@ -325,104 +430,6 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── Active alerts ───────────────────────────────────────────────── -->
|
||||
<section class="g-section">
|
||||
<div class="g-section-header">
|
||||
<h2 class="g-section-title">Active Alerts</h2>
|
||||
<span class="g-section-badge" id="alert-count-badge"
|
||||
{% if not summary.critical and not summary.warning %}style="display:none"{% endif %}>{{ (summary.critical or 0) + (summary.warning or 0) }}</span>
|
||||
</div>
|
||||
<div class="lt-toolbar">
|
||||
<div class="lt-toolbar-left">
|
||||
<div class="lt-search">
|
||||
<input type="search" class="lt-input lt-search-input" id="events-search"
|
||||
placeholder="Filter by target, type, description…" autocomplete="off">
|
||||
</div>
|
||||
<div class="sev-pills">
|
||||
<button type="button" class="pill active" data-sev="">All</button>
|
||||
<button type="button" class="pill" data-sev="critical">Critical</button>
|
||||
<button type="button" class="pill" data-sev="warning">Warning</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-frame">
|
||||
<span class="lt-frame-bl">╚</span>
|
||||
<span class="lt-frame-br">╝</span>
|
||||
<div class="lt-section-header">Alert Queue</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 — <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 data-tooltip="Alert severity level" data-tooltip-pos="bottom">Sev</th>
|
||||
<th>Type</th>
|
||||
<th>Target</th>
|
||||
<th>Detail</th>
|
||||
<th>Description</th>
|
||||
<th data-tooltip="When this alert was first raised" data-tooltip-pos="bottom">First Seen</th>
|
||||
<th data-tooltip="Most recent check failure" data-tooltip-pos="bottom">Last Seen</th>
|
||||
<th data-tooltip="Consecutive check failures since first seen" data-tooltip-pos="bottom">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" aria-label="Suppress alert for {{ e.target_name }}">🔕</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<tr><td colspan="10">
|
||||
<div class="lt-empty-state lt-empty-state--sm">
|
||||
<div class="lt-empty-state-icon">✔</div>
|
||||
<div class="lt-empty-state-title">No active alerts</div>
|
||||
</div>
|
||||
</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="lt-empty-state lt-empty-state--sm">
|
||||
<div class="lt-empty-state-icon">✔</div>
|
||||
<div class="lt-empty-state-title">No active alerts</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div><!-- /#events-table-wrap -->
|
||||
</div><!-- /.lt-frame -->
|
||||
</section>
|
||||
|
||||
<!-- ── Recently Resolved (last 24h) ───────────────────────────────── -->
|
||||
{% if recent_resolved %}
|
||||
<section class="g-section">
|
||||
@@ -452,59 +459,6 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── Quick-suppress modal ─────────────────────────────────────────── -->
|
||||
<div id="suppress-modal" class="lt-modal-overlay"
|
||||
role="dialog" aria-modal="true" aria-labelledby="suppress-modal-title" aria-hidden="true">
|
||||
<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" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<form id="suppress-form">
|
||||
<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">
|
||||
<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" data-duration="30">30 min</button>
|
||||
<button type="button" class="pill" data-duration="60">1 hr</button>
|
||||
<button type="button" class="pill" data-duration="240">4 hr</button>
|
||||
<button type="button" class="pill" data-duration="480">8 hr</button>
|
||||
<button type="button" class="pill pill-manual active" data-duration="">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" data-modal-close>Cancel</button>
|
||||
<button type="submit" class="lt-btn lt-btn-primary">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
@@ -519,8 +473,28 @@
|
||||
if (s.refreshInterval > 0) lt.autoRefresh.start(refreshAll, s.refreshInterval * 1000);
|
||||
};
|
||||
|
||||
document.getElementById('suppress-form')?.addEventListener('submit', submitSuppress);
|
||||
document.getElementById('sup-type')?.addEventListener('change', updateSuppressForm);
|
||||
// ── Topology collapse toggle ───────────────────────────────────
|
||||
(function() {
|
||||
var LS_KEY = 'gandalf_topo_collapsed';
|
||||
var btn = document.getElementById('topo-toggle-btn');
|
||||
var wrap = document.getElementById('topo-collapsible-wrap');
|
||||
if (!btn || !wrap) return;
|
||||
|
||||
function setCollapsed(v) {
|
||||
wrap.classList.toggle('is-collapsed', v);
|
||||
btn.setAttribute('aria-expanded', v ? 'false' : 'true');
|
||||
btn.textContent = v ? '▾ Expand' : '▴ Collapse';
|
||||
try { localStorage.setItem(LS_KEY, v ? '1' : '0'); } catch(_) {}
|
||||
}
|
||||
|
||||
var saved = false;
|
||||
try { saved = localStorage.getItem(LS_KEY) === '1'; } catch(_) {}
|
||||
setCollapsed(saved);
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
setCollapsed(!wrap.classList.contains('is-collapsed'));
|
||||
});
|
||||
})();
|
||||
|
||||
function updateEventAges() {
|
||||
document.querySelectorAll('.event-age[data-ts]').forEach(el => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="lt-page-header">
|
||||
<div>
|
||||
<h1 class="lt-page-title">Network Inspector</h1>
|
||||
<p class="g-page-sub" style="margin-top:4px">
|
||||
<p class="g-page-sub">
|
||||
Visual switch chassis diagrams. Click a port to see detailed stats and LLDP path debug.
|
||||
<span id="inspector-updated" style="margin-left:8px"></span>
|
||||
</p>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="lt-page-header">
|
||||
<div>
|
||||
<h1 class="lt-page-title">Link Debug</h1>
|
||||
<p class="g-page-sub" style="margin-top:4px">
|
||||
<p class="g-page-sub">
|
||||
Per-interface stats: speed, duplex, SFP optical levels, TX/RX rates, errors, and carrier changes.
|
||||
<span id="links-updated" style="margin-left:8px"></span>
|
||||
</p>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="lt-page-header">
|
||||
<div>
|
||||
<h1 class="lt-page-title">Alert Suppressions</h1>
|
||||
<p class="g-page-sub" style="margin-top:4px">Manage maintenance windows and per-target alert suppression rules.</p>
|
||||
<p class="g-page-sub">Manage maintenance windows and per-target alert suppression rules.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
<span class="lt-frame-bl">╚</span>
|
||||
<span class="lt-frame-br">╝</span>
|
||||
<div class="lt-section-header">Host & Interface Reference</div>
|
||||
<div style="padding:12px 14px">
|
||||
<div class="lt-section-body">
|
||||
<div class="targets-grid">
|
||||
{% for name, host in snapshot.hosts.items() %}
|
||||
<div class="target-card">
|
||||
|
||||
Reference in New Issue
Block a user