Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40a0c2af78 | |||
| 08543ac25a | |||
| 760e45bb68 |
@@ -10,6 +10,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -458,7 +459,7 @@ def api_avatar():
|
|||||||
|
|
||||||
# Build a safe cache filename from the username (alphanumeric + - _ .)
|
# Build a safe cache filename from the username (alphanumeric + - _ .)
|
||||||
safe_name = re.sub(r'[^a-zA-Z0-9._-]', '_', username)
|
safe_name = re.sub(r'[^a-zA-Z0-9._-]', '_', username)
|
||||||
cache_dir = ldap_cfg.get('cache_dir', '/tmp/gandalf_avatars')
|
cache_dir = ldap_cfg.get('cache_dir', os.path.join(tempfile.gettempdir(), 'gandalf_avatars'))
|
||||||
os.makedirs(cache_dir, exist_ok=True)
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
cache_file = os.path.join(cache_dir, f'user_{safe_name}.jpg')
|
cache_file = os.path.join(cache_dir, f'user_{safe_name}.jpg')
|
||||||
sentinel = os.path.join(cache_dir, f'user_{safe_name}.none')
|
sentinel = os.path.join(cache_dir, f'user_{safe_name}.none')
|
||||||
|
|||||||
@@ -222,10 +222,17 @@ def get_status_summary() -> dict:
|
|||||||
WHERE resolved_at IS NULL GROUP BY severity"""
|
WHERE resolved_at IS NULL GROUP BY severity"""
|
||||||
)
|
)
|
||||||
counts = {r['severity']: r['cnt'] for r in cur.fetchall()}
|
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 {
|
return {
|
||||||
'critical': counts.get('critical', 0),
|
'critical': counts.get('critical', 0),
|
||||||
'warning': counts.get('warning', 0),
|
'warning': counts.get('warning', 0),
|
||||||
'info': counts.get('info', 0),
|
'info': counts.get('info', 0),
|
||||||
|
'resolved_24h': resolved_24h,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -92,8 +92,10 @@ function updateStatusBar(summary, lastCheck, daemonOk) {
|
|||||||
// Update stat cards
|
// Update stat cards
|
||||||
const scCrit = document.getElementById('stat-critical-val');
|
const scCrit = document.getElementById('stat-critical-val');
|
||||||
const scWarn = document.getElementById('stat-warning-val');
|
const scWarn = document.getElementById('stat-warning-val');
|
||||||
|
const scRes = document.getElementById('stat-resolved-val');
|
||||||
if (scCrit) scCrit.textContent = critCount;
|
if (scCrit) scCrit.textContent = critCount;
|
||||||
if (scWarn) scWarn.textContent = warnCount;
|
if (scWarn) scWarn.textContent = warnCount;
|
||||||
|
if (scRes && summary.resolved_24h != null) scRes.textContent = summary.resolved_24h;
|
||||||
const statCritCard = document.getElementById('stat-critical');
|
const statCritCard = document.getElementById('stat-critical');
|
||||||
if (statCritCard) statCritCard.classList.toggle('lt-stat-card--alert', critCount > 0);
|
if (statCritCard) statCritCard.classList.toggle('lt-stat-card--alert', critCount > 0);
|
||||||
|
|
||||||
|
|||||||
@@ -706,38 +706,7 @@
|
|||||||
.poe-bar-warn { background: var(--amber); }
|
.poe-bar-warn { background: var(--amber); }
|
||||||
.poe-bar-crit { background: var(--red); }
|
.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 health summary */
|
|
||||||
.link-summary-panel {
|
|
||||||
background: var(--bg2);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.link-summary-panel.link-summary-has-alerts { border-color: var(--amber); }
|
|
||||||
.link-summary-grid { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-end; }
|
|
||||||
.link-summary-stat { min-width: 80px; }
|
|
||||||
.link-summary-stat.lss-alert .lss-label { color: var(--amber); }
|
|
||||||
.lss-label { display: block; font-size: .62em; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 2px; }
|
|
||||||
.lss-value { font-size: 1.2em; font-weight: bold; color: var(--text); }
|
|
||||||
.lss-sub { font-size: .7em; color: var(--text-muted); font-weight: normal; }
|
|
||||||
|
|
||||||
.link-loading { padding: 20px; text-align: center; color: var(--text-muted); font-size: .8em; }
|
.link-loading { padding: 20px; text-align: center; color: var(--text-muted); font-size: .8em; }
|
||||||
.link-loading::after { content: ' ...'; animation: blink 1s step-end infinite; }
|
.link-loading::after { content: ' ...'; animation: blink 1s step-end infinite; }
|
||||||
|
|||||||
+60
-38
@@ -208,6 +208,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Host cards -->
|
<!-- Host cards -->
|
||||||
|
<div class="lt-toolbar" style="margin-bottom:10px" 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">
|
<div class="host-grid" id="host-grid">
|
||||||
{% for name, host in snapshot.hosts.items() %}
|
{% for name, host in snapshot.hosts.items() %}
|
||||||
{% set suppressed = suppressions | selectattr('target_name', 'equalto', name) | list %}
|
{% set suppressed = suppressions | selectattr('target_name', 'equalto', name) | list %}
|
||||||
@@ -270,44 +278,49 @@
|
|||||||
<div class="g-section-header">
|
<div class="g-section-header">
|
||||||
<h2 class="g-section-title">UniFi Devices</h2>
|
<h2 class="g-section-title">UniFi Devices</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-table-wrap">
|
<div class="lt-frame">
|
||||||
<table class="lt-table" id="unifi-table">
|
<span class="lt-frame-bl">╚</span>
|
||||||
<caption class="lt-sr-only">UniFi network devices</caption>
|
<span class="lt-frame-br">╝</span>
|
||||||
<thead>
|
<div class="lt-section-header">Device Inventory</div>
|
||||||
<tr>
|
<div class="lt-table-wrap">
|
||||||
<th>Status</th>
|
<table class="lt-table" id="unifi-table">
|
||||||
<th>Name</th>
|
<caption class="lt-sr-only">UniFi network devices</caption>
|
||||||
<th>Type</th>
|
<thead>
|
||||||
<th>Model</th>
|
<tr>
|
||||||
<th>IP</th>
|
<th>Status</th>
|
||||||
<th>Actions</th>
|
<th>Name</th>
|
||||||
</tr>
|
<th>Type</th>
|
||||||
</thead>
|
<th>Model</th>
|
||||||
<tbody>
|
<th>IP</th>
|
||||||
{% for d in snapshot.unifi %}
|
<th>Actions</th>
|
||||||
<tr class="{% if not d.connected %}row-critical{% endif %}">
|
</tr>
|
||||||
<td>
|
</thead>
|
||||||
<span class="dot-{{ 'up' if d.connected else 'down' }}"></span>
|
<tbody>
|
||||||
{{ 'ONLINE' if d.connected else 'OFFLINE' }}
|
{% for d in snapshot.unifi %}
|
||||||
</td>
|
<tr class="{% if not d.connected %}row-critical{% endif %}">
|
||||||
<td><strong>{{ d.name }}</strong></td>
|
<td>
|
||||||
<td>{{ d.type }}</td>
|
<span class="dot-{{ 'up' if d.connected else 'down' }}"></span>
|
||||||
<td>{{ d.model }}</td>
|
{{ 'ONLINE' if d.connected else 'OFFLINE' }}
|
||||||
<td>{{ d.ip }}</td>
|
</td>
|
||||||
<td>
|
<td><strong>{{ d.name }}</strong></td>
|
||||||
{% if not d.connected %}
|
<td>{{ d.type }}</td>
|
||||||
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
|
<td>{{ d.model }}</td>
|
||||||
data-sup-type="unifi_device"
|
<td>{{ d.ip }}</td>
|
||||||
data-sup-name="{{ d.name }}"
|
<td>
|
||||||
data-sup-detail="">
|
{% if not d.connected %}
|
||||||
🔕 Suppress
|
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
|
||||||
</button>
|
data-sup-type="unifi_device"
|
||||||
{% endif %}
|
data-sup-name="{{ d.name }}"
|
||||||
</td>
|
data-sup-detail="">
|
||||||
</tr>
|
🔕 Suppress
|
||||||
{% endfor %}
|
</button>
|
||||||
</tbody>
|
{% endif %}
|
||||||
</table>
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -563,6 +576,15 @@
|
|||||||
new MutationObserver(applyEventsFilter)
|
new MutationObserver(applyEventsFilter)
|
||||||
.observe(document.getElementById('events-table-wrap'), { childList: true, subtree: true });
|
.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
|
// Stat card clicks — filter events table by severity
|
||||||
document.querySelectorAll('.lt-stat-card[data-stat-filter]').forEach(card => {
|
document.querySelectorAll('.lt-stat-card[data-stat-filter]').forEach(card => {
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
|
|||||||
+27
-5
@@ -13,6 +13,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-toolbar" id="links-toolbar" style="display:none">
|
||||||
|
<div class="lt-toolbar-left">
|
||||||
|
<div class="lt-search">
|
||||||
|
<input type="search" class="lt-input lt-search-input" id="links-search"
|
||||||
|
placeholder="Filter by host or switch name…" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-toolbar-right">
|
||||||
|
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="collapse-all">Collapse All</button>
|
||||||
|
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="expand-all">Expand All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="links-container">
|
<div id="links-container">
|
||||||
<div class="link-loading">Loading link statistics</div>
|
<div class="link-loading">Loading link statistics</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,7 +358,7 @@ function renderUnifiSwitches(unifiSwitches, dataUpdated) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).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 ───────────────────────────────────────
|
// ── Panel collapse / expand ───────────────────────────────────────
|
||||||
@@ -439,10 +452,6 @@ function renderLinks(data) {
|
|||||||
const parts = [];
|
const parts = [];
|
||||||
|
|
||||||
parts.push(buildLinkSummary(hosts, unifiSwitches));
|
parts.push(buildLinkSummary(hosts, unifiSwitches));
|
||||||
parts.push(`<div class="link-collapse-bar">
|
|
||||||
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="collapse-all">Collapse All</button>
|
|
||||||
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="expand-all">Expand All</button>
|
|
||||||
</div>`);
|
|
||||||
parts.push('<div class="link-host-list">');
|
parts.push('<div class="link-host-list">');
|
||||||
|
|
||||||
for (const [hostname, ifaces] of Object.entries(hosts)) {
|
for (const [hostname, ifaces] of Object.entries(hosts)) {
|
||||||
@@ -471,6 +480,17 @@ function renderLinks(data) {
|
|||||||
parts.push('</div>');
|
parts.push('</div>');
|
||||||
document.getElementById('links-container').innerHTML = parts.join('');
|
document.getElementById('links-container').innerHTML = parts.join('');
|
||||||
restoreCollapseState();
|
restoreCollapseState();
|
||||||
|
document.getElementById('links-toolbar').style.display = '';
|
||||||
|
applyLinksSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Host/switch search filter ─────────────────────────────────────
|
||||||
|
function applyLinksSearch() {
|
||||||
|
const q = (document.getElementById('links-search')?.value || '').trim().toLowerCase();
|
||||||
|
document.querySelectorAll('.link-host-panel').forEach(panel => {
|
||||||
|
const text = (panel.querySelector('.link-host-name')?.textContent || '').toLowerCase();
|
||||||
|
panel.style.display = (!q || text.includes(q)) ? '' : 'none';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapseAll() {
|
function collapseAll() {
|
||||||
@@ -552,5 +572,7 @@ document.addEventListener('click', e => {
|
|||||||
if (e.target.closest('[data-action="collapse-all"]')) { collapseAll(); return; }
|
if (e.target.closest('[data-action="collapse-all"]')) { collapseAll(); return; }
|
||||||
if (e.target.closest('[data-action="expand-all"]')) { expandAll(); return; }
|
if (e.target.closest('[data-action="expand-all"]')) { expandAll(); return; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('links-search')?.addEventListener('input', applyLinksSearch);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+18
-11
@@ -184,20 +184,27 @@
|
|||||||
<div class="g-section-header">
|
<div class="g-section-header">
|
||||||
<h2 class="g-section-title">Available Targets</h2>
|
<h2 class="g-section-title">Available Targets</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="targets-grid">
|
<div class="lt-frame">
|
||||||
{% for name, host in snapshot.hosts.items() %}
|
<span class="lt-frame-bl">╚</span>
|
||||||
<div class="target-card">
|
<span class="lt-frame-br">╝</span>
|
||||||
<div class="target-name">{{ name }}</div>
|
<div class="lt-section-header">Host & Interface Reference</div>
|
||||||
<div class="target-type">{{ 'Proxmox Host (prometheus)' if host.source == 'prometheus' else 'Ping-only host' }}</div>
|
<div style="padding:12px 14px">
|
||||||
{% if host.interfaces %}
|
<div class="targets-grid">
|
||||||
<div class="target-ifaces">
|
{% for name, host in snapshot.hosts.items() %}
|
||||||
{% for iface in host.interfaces.keys() | sort %}
|
<div class="target-card">
|
||||||
<code class="iface-chip">{{ iface }}</code>
|
<div class="target-name">{{ name }}</div>
|
||||||
|
<div class="target-type">{{ 'Proxmox Host (prometheus)' if host.source == 'prometheus' else 'Ping-only host' }}</div>
|
||||||
|
{% if host.interfaces %}
|
||||||
|
<div class="target-ifaces">
|
||||||
|
{% for iface in host.interfaces.keys() | sort %}
|
||||||
|
<code class="iface-chip">{{ iface }}</code>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user