Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15120a280f | |||
| 906869f425 | |||
| c027b5422a | |||
| d3e8191f26 | |||
| ed19838a4e | |||
| 7b4c263a40 | |||
| 40a0c2af78 |
@@ -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(),
|
||||
|
||||
@@ -222,10 +222,17 @@ def get_status_summary() -> dict:
|
||||
WHERE resolved_at IS NULL GROUP BY severity"""
|
||||
)
|
||||
counts = {r['severity']: r['cnt'] for r in cur.fetchall()}
|
||||
cur.execute(
|
||||
"""SELECT COUNT(*) as cnt FROM network_events
|
||||
WHERE resolved_at IS NOT NULL
|
||||
AND resolved_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)"""
|
||||
)
|
||||
resolved_24h = cur.fetchone()['cnt']
|
||||
return {
|
||||
'critical': counts.get('critical', 0),
|
||||
'warning': counts.get('warning', 0),
|
||||
'info': counts.get('info', 0),
|
||||
'resolved_24h': resolved_24h,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+12
-3
@@ -92,8 +92,10 @@ function updateStatusBar(summary, lastCheck, daemonOk) {
|
||||
// Update stat cards
|
||||
const scCrit = document.getElementById('stat-critical-val');
|
||||
const scWarn = document.getElementById('stat-warning-val');
|
||||
const scRes = document.getElementById('stat-resolved-val');
|
||||
if (scCrit) scCrit.textContent = critCount;
|
||||
if (scWarn) scWarn.textContent = warnCount;
|
||||
if (scRes && summary.resolved_24h !== null && summary.resolved_24h !== undefined) scRes.textContent = summary.resolved_24h;
|
||||
const statCritCard = document.getElementById('stat-critical');
|
||||
if (statCritCard) statCritCard.classList.toggle('lt-stat-card--alert', critCount > 0);
|
||||
|
||||
@@ -205,7 +207,7 @@ function updateEventsTable(events, totalActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const truncated = totalActive != null && totalActive > active.length;
|
||||
const truncated = totalActive !== null && totalActive !== undefined && totalActive > active.length;
|
||||
const countNotice = truncated
|
||||
? `<div class="pagination-notice">Showing ${active.length} of ${totalActive} active alerts — <a href="/api/events?limit=1000">view all via API</a></div>`
|
||||
: '';
|
||||
@@ -220,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>
|
||||
@@ -332,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
|
||||
|
||||
+62
-29
@@ -83,16 +83,64 @@
|
||||
.lt-main.lt-container { padding-top: calc(46px + var(--space-sm)); }
|
||||
}
|
||||
|
||||
/* ── Refresh button loading state ────────────────────────────────── */
|
||||
[data-action="refresh"].is-loading {
|
||||
/* ── Button loading state ─────────────────────────────────────────── */
|
||||
[data-action="refresh"].is-loading,
|
||||
.lt-btn.is-loading {
|
||||
opacity: .5;
|
||||
pointer-events: none;
|
||||
cursor: wait;
|
||||
position: relative;
|
||||
}
|
||||
[data-action="refresh"].is-loading::after {
|
||||
[data-action="refresh"].is-loading::after,
|
||||
.lt-btn.is-loading::after {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── Form group modifiers ────────────────────────────────────────── */
|
||||
.lt-form-group--last { margin-bottom: 0; }
|
||||
|
||||
/* ── Divider compact variant ─────────────────────────────────────── */
|
||||
.lt-divider--compact { margin: 1rem 0 0.75rem; }
|
||||
|
||||
/* ── 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); }
|
||||
@@ -142,13 +190,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) ───────────── */
|
||||
@@ -168,6 +209,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,
|
||||
@@ -177,7 +220,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); }
|
||||
|
||||
@@ -374,6 +416,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: '';
|
||||
@@ -431,6 +476,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); }
|
||||
@@ -480,6 +526,7 @@
|
||||
/* Bus rails */
|
||||
.topo-bus-section {
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -560,6 +607,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; }
|
||||
@@ -587,7 +638,6 @@
|
||||
.panel-toggle { font-size: .65em; color: var(--text-muted); flex-shrink: 0; margin-left: 6px; padding: 0 4px; border: 1px solid var(--border-color); }
|
||||
.link-host-panel.collapsed > .link-ifaces-grid { display: none; }
|
||||
|
||||
.link-collapse-bar { display: flex; gap: 8px; margin-bottom: 10px; }
|
||||
|
||||
.link-ifaces-grid {
|
||||
display: grid;
|
||||
@@ -706,23 +756,6 @@
|
||||
.poe-bar-warn { background: var(--amber); }
|
||||
.poe-bar-crit { background: var(--red); }
|
||||
|
||||
/* UniFi section divider */
|
||||
.unifi-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 24px 0 12px;
|
||||
color: var(--cyan);
|
||||
font-size: .75em;
|
||||
letter-spacing: .1em;
|
||||
}
|
||||
.unifi-section-header::before,
|
||||
.unifi-section-header::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
|
||||
}
|
||||
|
||||
|
||||
.link-loading { padding: 20px; text-align: center; color: var(--text-muted); font-size: .8em; }
|
||||
|
||||
+63
-38
@@ -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>
|
||||
@@ -210,7 +186,7 @@
|
||||
<span class="lt-footer-sep">|</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">[ S ]</span> SUPPRESS</span>
|
||||
<span class="lt-footer-sep">|</span>
|
||||
{% elif request.endpoint == 'links_page' %}
|
||||
{% elif request.endpoint in ('links_page', 'inspector') %}
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">[ R ]</span> REFRESH</span>
|
||||
<span class="lt-footer-sep">|</span>
|
||||
{% endif %}
|
||||
@@ -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">
|
||||
<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">
|
||||
<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="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">
|
||||
<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 lt-form-group--last">
|
||||
<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">
|
||||
@@ -229,11 +258,11 @@
|
||||
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="lt-modal-body">
|
||||
<table class="lt-table" style="width:100%">
|
||||
<table class="lt-table">
|
||||
<thead><tr><th>Shortcut</th><th>Action</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Ctrl / ⌘ + K</td><td>Command palette</td></tr>
|
||||
<tr><td>R</td><td>Refresh data (Dashboard / Link Debug)</td></tr>
|
||||
<tr><td>R</td><td>Refresh data (Dashboard / Link Debug / Inspector)</td></tr>
|
||||
<tr><td>S</td><td>Quick-suppress alert (Dashboard)</td></tr>
|
||||
<tr><td>*</td><td>Open settings</td></tr>
|
||||
<tr><td>?</td><td>Show this help</td></tr>
|
||||
@@ -266,17 +295,13 @@
|
||||
</div>
|
||||
<div class="lt-field-hint" id="settings-refresh-hint"></div>
|
||||
</div>
|
||||
<div class="lt-divider" style="margin:1rem 0 0.75rem"></div>
|
||||
<div class="lt-divider lt-divider--compact"></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>
|
||||
|
||||
+154
-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,14 +246,14 @@
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
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;">
|
||||
<div class="topo-bus-section">
|
||||
|
||||
<!-- 10G storage bus (Agg → VLAN90) -->
|
||||
<div class="topo-bus-10g">
|
||||
@@ -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,13 +305,21 @@
|
||||
<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" id="host-toolbar">
|
||||
<div class="lt-toolbar-left">
|
||||
<div class="lt-search">
|
||||
<input type="search" class="lt-input lt-search-input" id="host-search"
|
||||
placeholder="Filter hosts…" autocomplete="off" style="width:180px">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-grid" id="host-grid">
|
||||
{% for name, host in snapshot.hosts.items() %}
|
||||
{% set suppressed = suppressions | selectattr('target_name', 'equalto', name) | list %}
|
||||
@@ -249,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>
|
||||
@@ -262,6 +374,7 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div><!-- /#topo-collapsible-wrap -->
|
||||
</section>
|
||||
|
||||
<!-- ── UniFi devices ────────────────────────────────────────────────── -->
|
||||
@@ -317,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">
|
||||
@@ -444,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 %}
|
||||
@@ -511,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 => {
|
||||
@@ -568,6 +550,15 @@
|
||||
new MutationObserver(applyEventsFilter)
|
||||
.observe(document.getElementById('events-table-wrap'), { childList: true, subtree: true });
|
||||
|
||||
// Host grid search filter
|
||||
document.getElementById('host-search')?.addEventListener('input', function() {
|
||||
const q = this.value.trim().toLowerCase();
|
||||
document.querySelectorAll('#host-grid .host-card').forEach(card => {
|
||||
const name = (card.dataset.host || '').toLowerCase();
|
||||
card.style.display = (!q || name.includes(q)) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// Stat card clicks — filter events table by severity
|
||||
document.querySelectorAll('.lt-stat-card[data-stat-filter]').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
|
||||
@@ -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>
|
||||
@@ -358,7 +358,7 @@ function renderUnifiSwitches(unifiSwitches, dataUpdated) {
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="unifi-section-header">UNIFI SWITCH PORTS</div>${html}`;
|
||||
return `<div class="lt-divider" style="margin:20px 0 12px"><span class="lt-divider-label" style="color:var(--cyan);letter-spacing:.1em">UNIFI SWITCH PORTS</span></div>${html}`;
|
||||
}
|
||||
|
||||
// ── Panel collapse / expand ───────────────────────────────────────
|
||||
|
||||
@@ -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