feat: initial release — traefik, unifi, ups plugins

First-party plugins for Fabled Scryer (https://github.com/bvandeusen/fabledscryer).

traefik: Prometheus metrics scraping, stats history, access log parsing (GeoIP optional)
unifi:   UniFi controller integration — WAN health, devices, clients, DPI, events
ups:     UPS monitoring via NUT (Network UPS Tools), Ansible shutdown automation

Each plugin follows the Fabled Scryer plugin contract:
  setup(app), get_scheduled_tasks(), get_blueprint()

index.yaml lists all three plugins in the catalog format. Populate
checksum_sha256 entries once release zips are published via GitHub Releases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 18:28:43 -04:00
commit ad4fcd1cfd
50 changed files with 4808 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
{# plugins/unifi/templates/unifi/dpi.html #}
{% if not snapshot %}
<div class="card">
<p style="color:var(--text-muted);">No DPI data yet. DPI must be enabled on the UniFi controller.</p>
</div>
{% else %}
<div style="column-width:340px;column-count:3;column-gap:1rem;">
{# Category breakdown #}
{% if categories %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Traffic by Category
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">{{ snapshot.scraped_at.strftime("%H:%M") }}</span>
</div>
{% set total_bytes = categories | sum(attribute='rx_bytes') + categories | sum(attribute='tx_bytes') %}
<table class="table" style="width:100%;font-size:0.82rem;">
{% for cat in categories[:20] %}
{% set bytes = cat.rx_bytes + cat.tx_bytes %}
{% set pct = (bytes / total_bytes * 100) if total_bytes else 0 %}
<tr>
<td style="padding:0.25rem 0.5rem 0.25rem 0;">{{ cat.cat_name or cat.cat_id }}</td>
<td style="padding:0.25rem 0.3rem;text-align:right;color:var(--text-muted);font-size:0.78rem;white-space:nowrap;">
{% if bytes >= 1073741824 %}{{ "%.1f"|format(bytes / 1073741824) }} GB
{% elif bytes >= 1048576 %}{{ "%.1f"|format(bytes / 1048576) }} MB
{% elif bytes >= 1024 %}{{ "%.0f"|format(bytes / 1024) }} KB
{% else %}{{ bytes }} B{% endif %}
</td>
<td style="padding:0.25rem 0;width:4rem;">
<div style="background:var(--border);border-radius:2px;height:6px;overflow:hidden;">
<div style="background:var(--accent);height:100%;width:{{ "%.1f"|format(pct) }}%;"></div>
</div>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Top clients by DPI #}
{% if top_clients %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Top Clients by Traffic</div>
<table class="table" style="width:100%;font-size:0.82rem;">
{% for c in top_clients %}
<tr>
<td style="padding:0.25rem 0.5rem 0.25rem 0;word-break:break-all;">
{{ c.hostname or c.mac }}
<span style="color:var(--text-dim);font-size:0.75rem;font-family:monospace;margin-left:0.3rem;">{{ c.mac }}</span>
</td>
<td style="padding:0.25rem 0;text-align:right;font-size:0.78rem;color:var(--text-muted);white-space:nowrap;">
{% set bytes = c.bytes %}
{% if bytes >= 1073741824 %}{{ "%.1f"|format(bytes / 1073741824) }} GB
{% elif bytes >= 1048576 %}{{ "%.1f"|format(bytes / 1048576) }} MB
{% elif bytes >= 1024 %}{{ "%.0f"|format(bytes / 1024) }} KB
{% else %}{{ bytes }} B{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
{% endif %}
+52
View File
@@ -0,0 +1,52 @@
{# plugins/unifi/templates/unifi/events.html #}
{% if not events %}
<div class="card">
<p style="color:var(--text-muted);">No events in the last {{ range_key }}.</p>
</div>
{% else %}
<div class="card">
<div class="section-title" style="margin-bottom:0.75rem;">
Site Events
<span style="float:right;font-weight:normal;font-size:0.8rem;color:var(--text-dim);">{{ events|length }} in {{ range_key }}</span>
</div>
<table class="table" style="width:100%;font-size:0.82rem;">
<colgroup>
<col style="width:12rem;">
<col style="width:5rem;">
<col style="width:5rem;">
<col>
</colgroup>
<tr style="color:var(--text-muted);font-size:0.78rem;">
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.4rem 0;">Time</th>
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.4rem;">Category</th>
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.4rem;">Key</th>
<th style="text-align:left;font-weight:normal;padding:0 0 0.4rem;">Message</th>
</tr>
{% for ev in events %}
<tr style="{% if loop.index is divisibleby 2 %}background:var(--row-alt,rgba(255,255,255,0.02));{% endif %}">
<td style="padding:0.25rem 0.5rem 0.25rem 0;color:var(--text-dim);font-size:0.77rem;white-space:nowrap;">
{{ ev.event_time.strftime("%Y-%m-%d %H:%M:%S") }}
</td>
<td style="padding:0.25rem 0.5rem;">
<span style="font-size:0.75rem;padding:0.1rem 0.4rem;border-radius:3px;background:
{% if ev.category == 'wlan' %}rgba(96,96,192,0.25)
{% elif ev.category == 'wan' %}rgba(96,160,96,0.25)
{% elif ev.category == 'ids' %}rgba(192,64,64,0.25)
{% else %}rgba(128,128,128,0.15){% endif %};">
{{ ev.category }}
</span>
</td>
<td style="padding:0.25rem 0.5rem;color:var(--text-dim);font-family:monospace;font-size:0.75rem;">
{{ ev.event_key.replace("EVT_", "") if ev.event_key else "" }}
</td>
<td style="padding:0.25rem 0;color:var(--text);">
{{ ev.message }}
{% if ev.source_mac %}
<span style="color:var(--text-dim);font-size:0.75rem;font-family:monospace;margin-left:0.4rem;">{{ ev.source_mac }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
+63
View File
@@ -0,0 +1,63 @@
{# plugins/unifi/templates/unifi/index.html #}
{% extends "base.html" %}
{% block title %}UniFi — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;margin-bottom:1.25rem;">
<div style="display:flex;align-items:baseline;gap:1rem;">
<h1 class="page-title" style="margin-bottom:0;">UniFi Network</h1>
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
</div>
{% include "_time_range.html" %}
</div>
{# Tab nav #}
<div style="display:flex;gap:0.25rem;margin-bottom:1.25rem;border-bottom:1px solid var(--border);padding-bottom:0;">
{% set tabs = [
("overview", "Overview", "/plugins/unifi/rows"),
("events", "Events", "/plugins/unifi/events"),
("dpi", "Traffic", "/plugins/unifi/dpi"),
("security", "Security", "/plugins/unifi/security"),
("inventory", "Inventory", "/plugins/unifi/inventory"),
] %}
{% for tab_id, label, url in tabs %}
<button
class="tab-btn{% if loop.first %} tab-active{% endif %}"
data-tab="{{ tab_id }}"
data-url="{{ url }}"
onclick="setUnifiTab(this)"
style="background:none;border:none;border-bottom:2px solid {% if loop.first %}var(--accent){% else %}transparent{% endif %};padding:0.4rem 0.9rem;cursor:pointer;font-size:0.88rem;color:{% if loop.first %}var(--text){% else %}var(--text-muted){% endif %};margin-bottom:-1px;">
{{ label }}
</button>
{% endfor %}
</div>
<div id="unifi-content"
hx-get="/plugins/unifi/rows"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
</div>
<script>
function setUnifiTab(btn) {
document.querySelectorAll('.tab-btn').forEach(function(b) {
b.style.borderBottomColor = 'transparent';
b.style.color = 'var(--text-muted)';
});
btn.style.borderBottomColor = 'var(--accent)';
btn.style.color = 'var(--text)';
var el = document.getElementById('unifi-content');
el.setAttribute('hx-get', btn.dataset.url);
// Remove auto-poll on non-overview tabs (inventory/security don't change rapidly)
var isLive = btn.dataset.tab === 'overview' || btn.dataset.tab === 'events';
el.setAttribute('hx-trigger',
isLive
? 'tabActivated, every {{ poll_interval }}s, rangeChange from:body'
: 'tabActivated, rangeChange from:body'
);
htmx.process(el);
htmx.trigger(el, 'tabActivated');
}
</script>
{% endblock %}
+146
View File
@@ -0,0 +1,146 @@
{# plugins/unifi/templates/unifi/inventory.html #}
<div style="column-width:360px;column-count:3;column-gap:1rem;">
{# Networks / VLANs #}
{% if networks %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Networks / VLANs</div>
<table class="table" style="width:100%;font-size:0.82rem;">
<tr style="color:var(--text-muted);font-size:0.78rem;">
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem 0;">Name</th>
<th style="text-align:right;font-weight:normal;padding:0 0.5rem 0.3rem;">VLAN</th>
<th style="text-align:left;font-weight:normal;padding:0 0 0.3rem;">Subnet</th>
</tr>
{% for net in networks %}
<tr>
<td style="padding:0.25rem 0.5rem 0.25rem 0;">
{{ net.name }}
{% if net.is_guest %}<span style="font-size:0.72rem;color:var(--text-dim);"> guest</span>{% endif %}
</td>
<td style="padding:0.25rem 0.5rem;text-align:right;color:var(--text-muted);font-family:monospace;font-size:0.78rem;">
{{ net.vlan or "—" }}
</td>
<td style="padding:0.25rem 0;color:var(--text-dim);font-family:monospace;font-size:0.78rem;">
{{ net.ip_subnet or "—" }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Port forwards #}
{% if port_forwards %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Port Forwards</div>
<table class="table" style="width:100%;font-size:0.82rem;">
{% for pf in port_forwards %}
<tr>
<td style="padding:0.25rem 0.5rem 0.25rem 0;">
<span style="{% if not pf.enabled %}color:var(--text-dim);{% endif %}">
{{ pf.name or "—" }}
</span>
{% if not pf.enabled %}<span style="font-size:0.72rem;color:var(--text-dim);"> disabled</span>{% endif %}
</td>
<td style="padding:0.25rem 0;font-family:monospace;font-size:0.78rem;color:var(--text-muted);white-space:nowrap;">
{{ pf.proto }}:{{ pf.dst_port }} → {{ pf.fwd_ip }}:{{ pf.fwd_port }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Firewall rules, grouped by ruleset #}
{% if fw_rules %}
{% set ns = namespace(current_rs=None) %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Firewall Rules</div>
<table class="table" style="width:100%;font-size:0.82rem;">
{% for rule in fw_rules %}
{% if rule.ruleset != ns.current_rs %}
{% set ns.current_rs = rule.ruleset %}
<tr>
<td colspan="3" style="padding:0.4rem 0 0.2rem;color:var(--text-muted);font-size:0.75rem;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;">
{{ rule.ruleset }}
</td>
</tr>
{% endif %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">
<span style="{% if not rule.enabled %}color:var(--text-dim);{% endif %}">
{{ rule.name }}
</span>
</td>
<td style="padding:0.2rem 0.3rem;white-space:nowrap;">
<span style="font-size:0.75rem;padding:0.1rem 0.35rem;border-radius:3px;background:
{% if rule.action == 'accept' %}rgba(64,160,64,0.2)
{% elif rule.action == 'drop' %}rgba(192,64,64,0.2)
{% else %}rgba(192,128,64,0.2){% endif %};">
{{ rule.action }}
</span>
</td>
<td style="padding:0.2rem 0;color:var(--text-dim);font-size:0.76rem;">
{% if rule.protocol %}{{ rule.protocol }}{% endif %}
{% if rule.src_address %} src:{{ rule.src_address }}{% endif %}
{% if rule.dst_address %} dst:{{ rule.dst_address }}{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Known clients #}
{% if known_clients %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Known Clients
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">{{ known_clients|length }} total</span>
</div>
<table class="table" style="width:100%;font-size:0.82rem;">
<tr style="color:var(--text-muted);font-size:0.78rem;">
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem 0;">Name / MAC</th>
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem;">IP</th>
<th style="text-align:right;font-weight:normal;padding:0 0 0.3rem;">Last Seen</th>
</tr>
{% for kc in known_clients[:50] %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">
<div style="{% if kc.is_blocked %}color:var(--red);{% endif %}">
{{ kc.name or kc.hostname or kc.mac }}
{% if kc.is_blocked %}<span style="font-size:0.72rem;"> blocked</span>{% endif %}
</div>
{% if kc.name or kc.hostname %}
<div style="color:var(--text-dim);font-size:0.74rem;font-family:monospace;">{{ kc.mac }}</div>
{% endif %}
{% if kc.mac_vendor %}
<div style="color:var(--text-dim);font-size:0.73rem;">{{ kc.mac_vendor }}</div>
{% endif %}
</td>
<td style="padding:0.2rem 0.3rem;color:var(--text-muted);font-family:monospace;font-size:0.78rem;">
{{ kc.ip or "—" }}
</td>
<td style="padding:0.2rem 0;text-align:right;color:var(--text-dim);font-size:0.75rem;white-space:nowrap;">
{% if kc.last_seen %}{{ kc.last_seen.strftime("%m-%d %H:%M") }}{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
{% if known_clients|length > 50 %}
<tr>
<td colspan="3" style="padding:0.4rem 0;color:var(--text-dim);font-size:0.78rem;text-align:center;">
+{{ known_clients|length - 50 }} more
</td>
</tr>
{% endif %}
</table>
</div>
{% endif %}
{% if not networks and not port_forwards and not fw_rules and not known_clients %}
<div class="card">
<p style="color:var(--text-muted);">No inventory data yet. This populates on the next poll cycle.</p>
</div>
{% endif %}
</div>
+203
View File
@@ -0,0 +1,203 @@
{# plugins/unifi/templates/unifi/rows.html #}
{# ── WAN health bar ───────────────────────────────────────────────────────── #}
{% if latest_wan %}
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--card-bg);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;align-items:center;">
<span>
<span style="color:var(--text-muted);">WAN</span>
<span style="margin-left:0.4rem;font-weight:600;color:{% if latest_wan.is_up %}var(--green){% else %}var(--red){% endif %};">
{% if latest_wan.is_up %}&#10003; Up{% else %}&#10007; Down{% endif %}
</span>
{% if latest_wan.wan_ip %}
<span style="color:var(--text-dim);margin-left:0.4rem;font-family:monospace;font-size:0.8rem;">{{ latest_wan.wan_ip }}</span>
{% endif %}
</span>
{% if latest_wan.latency_ms is not none %}
<span>
<span style="color:var(--text-muted);">Latency</span>
<span style="margin-left:0.4rem;
{% if latest_wan.latency_ms > 100 %}color:var(--red){% elif latest_wan.latency_ms > 50 %}color:var(--yellow){% else %}color:var(--green){% endif %};">
{{ "%.1f"|format(latest_wan.latency_ms) }}ms
</span>
<span style="margin-left:0.3rem;">{{ sparkline_latency | safe }}</span>
</span>
{% endif %}
{% if latest_wan.rx_bytes_rate is not none %}
<span>
<span style="color:var(--text-muted);">&#8595; RX</span>
<span style="margin-left:0.3rem;">
{% set bps = latest_wan.rx_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }} MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }} KB/s
{% else %}{{ "%.0f"|format(bps) }} B/s{% endif %}
</span>
<span style="margin-left:0.3rem;">{{ sparkline_rx | safe }}</span>
</span>
<span>
<span style="color:var(--text-muted);">&#8593; TX</span>
<span style="margin-left:0.3rem;">
{% set bps = latest_wan.tx_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }} MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }} KB/s
{% else %}{{ "%.0f"|format(bps) }} B/s{% endif %}
</span>
<span style="margin-left:0.3rem;">{{ sparkline_tx | safe }}</span>
</span>
{% endif %}
{% if latest_wan.uptime_seconds is not none %}
<span style="color:var(--text-dim);font-size:0.8rem;">
up {{ (latest_wan.uptime_seconds // 3600) }}h {{ ((latest_wan.uptime_seconds % 3600) // 60) }}m
</span>
{% endif %}
</div>
{% endif %}
{# ── Content columns ──────────────────────────────────────────────────────── #}
<div style="column-width:340px;column-count:3;column-gap:1rem;">
{# Client summary card #}
{% if latest_clients %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Clients
<span style="float:right;font-weight:normal;color:var(--text-muted);font-size:0.82rem;">{{ sparkline_clients | safe }}</span>
</div>
<div style="display:flex;gap:1.5rem;font-size:0.9rem;margin-bottom:0.75rem;font-variant-numeric:tabular-nums;">
<span>
<span style="font-size:1.4rem;font-weight:700;color:var(--text);">{{ latest_clients.total_clients }}</span>
<span style="color:var(--text-muted);font-size:0.8rem;margin-left:0.2rem;">total</span>
</span>
<span style="color:var(--text-muted);font-size:0.85rem;align-self:flex-end;">
{{ latest_clients.wireless_clients }}&#x2197; wireless &nbsp;
{{ latest_clients.wired_clients }}&#x2194; wired
{% if latest_clients.guest_clients %}&nbsp; {{ latest_clients.guest_clients }} guest{% endif %}
</span>
</div>
</div>
{% endif %}
{# Infrastructure devices card #}
{% if devices %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Infrastructure Devices</div>
<table class="table" style="font-size:0.83rem;width:100%;">
{% for d in devices %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">
<span style="color:{% if d.state == 1 %}var(--green){% else %}var(--red){% endif %};">&#9679;</span>
<span style="margin-left:0.3rem;">{{ d.name or d.mac }}</span>
</td>
<td style="padding:0.2rem 0.3rem;color:var(--text-muted);font-size:0.78rem;">{{ d.model or d.device_type or "" }}</td>
<td style="text-align:right;padding:0.2rem 0;color:var(--text-dim);font-size:0.78rem;font-family:monospace;">{{ d.ip or "" }}</td>
</tr>
{% if d.state == 1 and d.uptime_seconds %}
<tr>
<td colspan="3" style="padding:0 0 0.3rem 1.2rem;color:var(--text-dim);font-size:0.75rem;">
up {{ (d.uptime_seconds // 86400) }}d {{ ((d.uptime_seconds % 86400) // 3600) }}h
</td>
</tr>
{% endif %}
{% endfor %}
</table>
</div>
{% endif %}
{# Top talkers card #}
{% if top_clients %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Top Talkers</div>
<table class="table" style="font-size:0.82rem;width:100%;">
{% for c in top_clients %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;word-break:break-all;">
{{ c.hostname }}
{% if c.type == "wireless" and c.signal %}
<span style="color:var(--text-dim);font-size:0.75rem;"> {{ c.signal }}dBm</span>
{% endif %}
</td>
<td style="text-align:right;padding:0.2rem 0;white-space:nowrap;font-size:0.78rem;">
{% set rx = c.rx_rate %}{% set tx = c.tx_rate %}
<span style="color:var(--text-muted);">&#8595;</span>
{% if rx >= 1048576 %}{{ "%.1f"|format(rx/1048576) }}M
{% elif rx >= 1024 %}{{ "%.0f"|format(rx/1024) }}K
{% else %}{{ "%.0f"|format(rx) }}B{% endif %}
<span style="color:var(--text-muted);margin-left:0.3rem;">&#8593;</span>
{% if tx >= 1048576 %}{{ "%.1f"|format(tx/1048576) }}M
{% elif tx >= 1024 %}{{ "%.0f"|format(tx/1024) }}K
{% else %}{{ "%.0f"|format(tx) }}B{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# WLAN SSIDs card #}
{% if wlan_latest %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Wireless Networks</div>
<table class="table" style="font-size:0.83rem;width:100%;">
<tr style="color:var(--text-muted);font-size:0.78rem;">
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem 0;">SSID</th>
<th style="text-align:right;font-weight:normal;padding:0 0.3rem 0.3rem;">Clients</th>
<th style="text-align:right;font-weight:normal;padding:0 0 0.3rem;">Score</th>
</tr>
{% for w in wlan_latest %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">{{ w.ssid }}</td>
<td style="text-align:right;padding:0.2rem 0.3rem;">{{ w.num_sta }}</td>
<td style="text-align:right;padding:0.2rem 0;
{% if w.satisfaction is not none %}
{% if w.satisfaction >= 80 %}color:var(--green){% elif w.satisfaction >= 60 %}color:var(--yellow){% else %}color:var(--red){% endif %}
{% endif %};">
{% if w.satisfaction is not none %}{{ w.satisfaction }}%{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Speedtest card #}
{% if speedtest %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Last Speed Test</div>
<div style="display:flex;gap:1.5rem;font-size:0.88rem;font-variant-numeric:tabular-nums;">
{% if speedtest.download_mbps is not none %}
<span>
<span style="color:var(--text-muted);font-size:0.8rem;">&#8595; DL</span>
<span style="font-weight:600;margin-left:0.3rem;">{{ "%.1f"|format(speedtest.download_mbps) }}</span>
<span style="color:var(--text-muted);font-size:0.78rem;">Mbps</span>
</span>
{% endif %}
{% if speedtest.upload_mbps is not none %}
<span>
<span style="color:var(--text-muted);font-size:0.8rem;">&#8593; UL</span>
<span style="font-weight:600;margin-left:0.3rem;">{{ "%.1f"|format(speedtest.upload_mbps) }}</span>
<span style="color:var(--text-muted);font-size:0.78rem;">Mbps</span>
</span>
{% endif %}
{% if speedtest.latency_ms is not none %}
<span>
<span style="color:var(--text-muted);font-size:0.8rem;">Latency</span>
<span style="margin-left:0.3rem;">{{ "%.0f"|format(speedtest.latency_ms) }}ms</span>
</span>
{% endif %}
</div>
{% if speedtest.server_name %}
<div style="margin-top:0.4rem;color:var(--text-dim);font-size:0.78rem;">via {{ speedtest.server_name }}</div>
{% endif %}
<div style="margin-top:0.25rem;color:var(--text-dim);font-size:0.75rem;">{{ speedtest.run_at.strftime("%Y-%m-%d %H:%M") }} UTC</div>
</div>
{% endif %}
</div>
{% if not latest_wan and not devices %}
<div class="card">
<p style="color:var(--text-muted);">No UniFi data yet. Check your <code>host</code>, <code>username</code>, and <code>password</code> in the plugin config.</p>
</div>
{% endif %}
+56
View File
@@ -0,0 +1,56 @@
{# plugins/unifi/templates/unifi/security.html #}
<div style="column-width:340px;column-count:2;column-gap:1rem;">
{# Active alarms #}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Active Alarms
{% if active_alarms %}
<span style="float:right;background:var(--red);color:#fff;font-size:0.72rem;padding:0.1rem 0.45rem;border-radius:10px;font-weight:600;">{{ active_alarms|length }}</span>
{% endif %}
</div>
{% if not active_alarms %}
<p style="color:var(--green);font-size:0.88rem;">&#10003; No active alarms</p>
{% else %}
<table class="table" style="width:100%;font-size:0.82rem;">
{% for al in active_alarms %}
<tr>
<td style="padding:0.3rem 0.5rem 0.3rem 0;">
<div style="color:var(--red);font-size:0.78rem;margin-bottom:0.1rem;">
&#9888; {{ al.alarm_key }}
</div>
<div>{{ al.message }}</div>
<div style="color:var(--text-dim);font-size:0.75rem;margin-top:0.1rem;">
{{ al.alarm_time.strftime("%Y-%m-%d %H:%M") }} UTC
</div>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
{# Recent archived alarms #}
{% if archived_alarms %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">
Recent Archived Alarms
<span style="float:right;font-weight:normal;font-size:0.78rem;color:var(--text-dim);">last {{ archived_alarms|length }}</span>
</div>
<table class="table" style="width:100%;font-size:0.82rem;">
{% for al in archived_alarms %}
<tr>
<td style="padding:0.25rem 0.5rem 0.25rem 0;">
<div style="color:var(--text-muted);font-size:0.78rem;">{{ al.alarm_key }}</div>
<div style="color:var(--text-dim);font-size:0.8rem;">{{ al.message }}</div>
</td>
<td style="padding:0.25rem 0;text-align:right;color:var(--text-dim);font-size:0.75rem;white-space:nowrap;">
{{ al.alarm_time.strftime("%m-%d %H:%M") }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
+95
View File
@@ -0,0 +1,95 @@
{# plugins/unifi/templates/unifi/widget.html #}
{# Active alarms #}
{% for al in active_alarms %}
<div style="display:flex;justify-content:space-between;font-size:0.8rem;color:var(--red);padding:0.15rem 0;">
<span>&#9888; {{ al.alarm_key }}</span>
<span style="color:var(--text-dim);font-size:0.75rem;">alarm</span>
</div>
{% endfor %}
{# Offline device alerts #}
{% for d in offline_devices %}
<div style="display:flex;justify-content:space-between;font-size:0.8rem;color:var(--red);padding:0.15rem 0;">
<span>&#9888; {{ d.name or d.mac }}</span>
<span>offline</span>
</div>
{% endfor %}
{% if active_alarms or offline_devices %}<div style="height:0.4rem;"></div>{% endif %}
{% if latest_wan %}
<div class="ping-row">
<div class="ping-meta">
<span class="ping-name" style="font-size:0.8rem;">WAN</span>
</div>
<div style="flex:1;display:flex;gap:1.5rem;font-size:0.82rem;font-variant-numeric:tabular-nums;">
<span style="color:{% if latest_wan.is_up %}var(--green){% else %}var(--red){% endif %};">
{% if latest_wan.is_up %}Up{% else %}Down{% endif %}
</span>
{% if latest_wan.latency_ms is not none %}
<span>
<span style="color:var(--text-muted);">lat</span>
<span style="margin-left:0.2rem;{% if latest_wan.latency_ms > 100 %}color:var(--red){% elif latest_wan.latency_ms > 50 %}color:var(--yellow){% endif %};">
{{ "%.0f"|format(latest_wan.latency_ms) }}ms
</span>
</span>
{% endif %}
{% if latest_wan.rx_bytes_rate is not none %}
<span>
<span style="color:var(--text-muted);">&#8595;</span>
{% set bps = latest_wan.rx_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }}MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }}KB/s
{% else %}{{ "%.0f"|format(bps) }}B/s{% endif %}
</span>
<span>
<span style="color:var(--text-muted);">&#8593;</span>
{% set bps = latest_wan.tx_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }}MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }}KB/s
{% else %}{{ "%.0f"|format(bps) }}B/s{% endif %}
</span>
{% endif %}
</div>
</div>
{% endif %}
{% if latest_clients %}
<div class="ping-row">
<div class="ping-meta">
<span class="ping-name" style="font-size:0.8rem;">Clients</span>
</div>
<div style="flex:1;display:flex;gap:1rem;font-size:0.82rem;">
<span style="font-weight:600;">{{ latest_clients.total_clients }}</span>
<span style="color:var(--text-muted);">{{ latest_clients.wireless_clients }}w / {{ latest_clients.wired_clients }}e</span>
</div>
</div>
{% endif %}
{% if top_clients %}
<div style="margin-top:0.4rem;border-top:1px solid var(--border);padding-top:0.4rem;">
{% for c in top_clients %}
<div class="ping-row" style="padding:0.1rem 0;">
<div class="ping-meta" style="min-width:0;flex:1;">
<span style="font-size:0.78rem;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ c.hostname }}</span>
</div>
<div style="font-size:0.78rem;font-variant-numeric:tabular-nums;white-space:nowrap;color:var(--text-muted);">
{% set total_bps = c.tx_rate + c.rx_rate %}
<span style="color:var(--text-muted);">&#8595;</span>
{% set rx = c.rx_rate %}
{% if rx >= 1048576 %}{{ "%.1f"|format(rx / 1048576) }}M
{% elif rx >= 1024 %}{{ "%.0f"|format(rx / 1024) }}K
{% else %}{{ "%.0f"|format(rx) }}B{% endif %}
<span style="color:var(--text-muted);margin-left:0.4rem;">&#8593;</span>
{% set tx = c.tx_rate %}
{% if tx >= 1048576 %}{{ "%.1f"|format(tx / 1048576) }}M
{% elif tx >= 1024 %}{{ "%.0f"|format(tx / 1024) }}K
{% else %}{{ "%.0f"|format(tx) }}B{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if not latest_wan and not latest_clients and not active_alarms and not offline_devices %}
<p class="empty" style="padding:0.5rem 0;font-size:0.85rem;">No UniFi data yet.</p>
{% endif %}